diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index 6d80d66..80881af 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -84,6 +84,7 @@ import com.lemon.lifelog.asr.transcribeWithStats import com.lemon.lifelog.audio.SegmentPlayer import com.lemon.lifelog.chat.ChatConfig import com.lemon.lifelog.summary.SummaryProgress +import com.lemon.lifelog.summary.SummaryRunLog import com.lemon.lifelog.data.Repository import com.lemon.lifelog.data.SegmentRow import com.lemon.lifelog.data.SpeakerProfile @@ -840,6 +841,9 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon val backendTargets = remember { ChatConfig.targets(activity) } val running by SummaryProgress.running.collectAsState() val progressLog by SummaryProgress.log.collectAsState() + val runLog by SummaryRunLog.runs.collectAsState() + var showRuns by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { SummaryRunLog.load(activity) } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -909,6 +913,43 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon } } } + + // 执行记录(持久化):每次 worker 跑都记一行,确认 periodic 在正常运行。 + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth().clickable { showRuns = !showRuns }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(if (showRuns) "▾" else "▸") + Text("执行记录(${runLog.size})", style = MaterialTheme.typography.titleSmall) + runLog.lastOrNull()?.let { + Text("· 上次 ${RUN_FMT.format(Date(it.ts))} → ${it.made}篇", style = MaterialTheme.typography.labelSmall) + } + } + if (showRuns) { + Column( + modifier = Modifier.fillMaxWidth() + .heightIn(max = 180.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(8.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + if (runLog.isEmpty()) { + Text("还没有执行记录(worker 跑过一次后出现)。", style = MaterialTheme.typography.labelSmall) + } + runLog.asReversed().forEach { r -> + val tag = if (r.trigger == "manual") "手动" else "自动" + Text( + "${RUN_FMT.format(Date(r.ts))} $tag · ${r.total}会话→${r.made}篇" + + (if (r.skipped > 0) " (跳过${r.skipped})" else "") + + " · ${r.secs}s · ${r.backend}", + style = MaterialTheme.typography.labelSmall, + ) + } + } + } Spacer(Modifier.height(8.dp)) if (summaries.isEmpty()) { @@ -1276,6 +1317,7 @@ private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US) +private val RUN_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) private suspend fun enrollSpeakerForSegment(repo: Repository, segmentId: Long, name: String) { withContext(Dispatchers.IO) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryRunLog.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryRunLog.kt new file mode 100644 index 0000000..152ec3d --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryRunLog.kt @@ -0,0 +1,76 @@ +package com.lemon.lifelog.summary + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONArray +import org.json.JSONObject + +/** + * 持久化的纪要 worker 执行记录:每次 doWork 跑完记一行,存 lifelog_prefs(JSON)。 + * 让用户在纪要 tab 看到「几点几分跑了、出了几篇」,确认 periodic 在正常运行 + * (即使 0 篇——比如会话还没结束——也记一行,证明 worker 活着)。 + * + * worker 与 UI 同进程,singleton 共享;[runs] StateFlow 供 UI 实时刷新,prefs 保证重启不丢。 + */ +object SummaryRunLog { + private const val PREFS = "lifelog_prefs" + private const val KEY = "summary_runs" + private const val MAX = 50 + + data class Run( + val ts: Long, + val made: Int, + val skipped: Int, + val total: Int, + val secs: Int, + val backend: String, + val trigger: String, // "auto"(periodic) | "manual" + ) + + private val _runs = MutableStateFlow>(emptyList()) + val runs: StateFlow> = _runs + + /** UI 启动时调,从 prefs 载入历史。 */ + fun load(ctx: Context) { + if (_runs.value.isEmpty()) _runs.value = read(ctx) + } + + @Synchronized + fun add(ctx: Context, run: Run) { + val list = (read(ctx) + run).takeLast(MAX) + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY, encode(list)).apply() + _runs.value = list + } + + private fun read(ctx: Context): List { + val s = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null) ?: return emptyList() + return try { + val arr = JSONArray(s) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + Run( + o.getLong("ts"), o.getInt("made"), o.getInt("skipped"), o.getInt("total"), + o.getInt("secs"), o.optString("backend"), o.optString("trigger"), + ) + } + } catch (t: Throwable) { + emptyList() + } + } + + private fun encode(list: List): String { + val arr = JSONArray() + list.forEach { r -> + arr.put( + JSONObject().apply { + put("ts", r.ts); put("made", r.made); put("skipped", r.skipped) + put("total", r.total); put("secs", r.secs) + put("backend", r.backend); put("trigger", r.trigger) + }, + ) + } + return arr.toString() + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt index 9b19274..ac3535f 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt @@ -41,6 +41,7 @@ object SummaryScheduler { .getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN) val data = Data.Builder() .putLong(SummaryWorker.INPUT_GAP_MS, gapMin * 60_000L) + .putString(SummaryWorker.INPUT_TRIGGER, "manual") .build() val request = OneTimeWorkRequestBuilder() .setInputData(data) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt index 310fd78..49989ff 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt @@ -43,6 +43,7 @@ class SummaryWorker( const val INPUT_NOW_TS = "now_ts" // 覆盖 now(测试 / 手动) const val INPUT_GAP_MS = "gap_ms" // 覆盖会话间隔(手动跑用当前设置) + const val INPUT_TRIGGER = "trigger" // "manual"(现在跑一次);periodic 不传 = "auto" const val PREFS = "lifelog_prefs" const val KEY_GAP_MIN = "session_gap_min" @@ -85,6 +86,8 @@ class SummaryWorker( private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) private val shortFmt = SimpleDateFormat("MM-dd HH:mm", Locale.US) + private fun secs(start: Long) = ((System.currentTimeMillis() - start) / 1000).toInt() + override suspend fun doWork(): Result { if (!isRunning.compareAndSet(false, true)) { Log.i(TAG, "another run in progress, skip") @@ -110,12 +113,14 @@ class SummaryWorker( applicationContext, prefs.getString(KEY_BACKEND, com.lemon.lifelog.chat.ChatConfig.ID_2B), ) + val trigger = inputData.getString(INPUT_TRIGGER) ?: "auto" SummaryProgress.start("开始 · 后端 ${target.label} · 间隔 ${gapMs / 60_000}min") val repo = Repository(applicationContext) val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs } if (segments.isEmpty()) { SummaryProgress.finish("没有录音段,结束") + SummaryRunLog.add(applicationContext, SummaryRunLog.Run(now, 0, 0, 0, secs(runStart), target.label, trigger)) Log.i(TAG, "no segments"); return Result.success() } @@ -175,6 +180,7 @@ class SummaryWorker( } SummaryProgress.finish("完成:$made 篇生成,$skipped 个会话跳过(共 ${sessions.size})") + SummaryRunLog.add(applicationContext, SummaryRunLog.Run(now, made, skipped, sessions.size, secs(runStart), target.label, trigger)) Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)") // 不 retry:失败/未处理的会话下一轮 periodic 或手动「现在跑一次」会重新捡, // 避免 502 后 WorkManager 死循环 hammering。