Commit d2837118c274d0de2aedc4d8102b4805b5e486d7
1 parent
1f08686e
feat(lifelog/summary): 纪要 tab 加持久化「执行记录」list
只有结果 list 看不出 worker 有没有正常跑(periodic 经常 0 篇——会话没结束)。 加一条持久化执行记录,确认「几点几分跑了、几个会话→几篇」。 - SummaryRunLog:singleton + lifelog_prefs(JSON) 存最近 50 次执行; StateFlow 供 UI 实时刷新,prefs 保证重启不丢。 - SummaryWorker:每次 doWork 跑完(含 0 段早退)记一行 Run(ts/made/skipped/ total/secs/backend/trigger);trigger 区分 auto(periodic)/manual。 - SummaryScheduler.runNow:标 trigger=manual。 - SummariesTab:折叠「执行记录(N)· 上次 HHss → M篇」,展开看历次。 验证:手动触发 → prefs 记 {made:0,skipped:9,total:9,trigger:manual} → UI 正常显示。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
4 changed files
with
125 additions
and
0 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| ... | ... | @@ -84,6 +84,7 @@ import com.lemon.lifelog.asr.transcribeWithStats |
| 84 | 84 | import com.lemon.lifelog.audio.SegmentPlayer |
| 85 | 85 | import com.lemon.lifelog.chat.ChatConfig |
| 86 | 86 | import com.lemon.lifelog.summary.SummaryProgress |
| 87 | +import com.lemon.lifelog.summary.SummaryRunLog | |
| 87 | 88 | import com.lemon.lifelog.data.Repository |
| 88 | 89 | import com.lemon.lifelog.data.SegmentRow |
| 89 | 90 | import com.lemon.lifelog.data.SpeakerProfile |
| ... | ... | @@ -840,6 +841,9 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon |
| 840 | 841 | val backendTargets = remember { ChatConfig.targets(activity) } |
| 841 | 842 | val running by SummaryProgress.running.collectAsState() |
| 842 | 843 | val progressLog by SummaryProgress.log.collectAsState() |
| 844 | + val runLog by SummaryRunLog.runs.collectAsState() | |
| 845 | + var showRuns by remember { mutableStateOf(false) } | |
| 846 | + LaunchedEffect(Unit) { SummaryRunLog.load(activity) } | |
| 843 | 847 | |
| 844 | 848 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { |
| 845 | 849 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| ... | ... | @@ -909,6 +913,43 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon |
| 909 | 913 | } |
| 910 | 914 | } |
| 911 | 915 | } |
| 916 | + | |
| 917 | + // 执行记录(持久化):每次 worker 跑都记一行,确认 periodic 在正常运行。 | |
| 918 | + Spacer(Modifier.height(6.dp)) | |
| 919 | + Row( | |
| 920 | + modifier = Modifier.fillMaxWidth().clickable { showRuns = !showRuns }, | |
| 921 | + verticalAlignment = Alignment.CenterVertically, | |
| 922 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 923 | + ) { | |
| 924 | + Text(if (showRuns) "▾" else "▸") | |
| 925 | + Text("执行记录(${runLog.size})", style = MaterialTheme.typography.titleSmall) | |
| 926 | + runLog.lastOrNull()?.let { | |
| 927 | + Text("· 上次 ${RUN_FMT.format(Date(it.ts))} → ${it.made}篇", style = MaterialTheme.typography.labelSmall) | |
| 928 | + } | |
| 929 | + } | |
| 930 | + if (showRuns) { | |
| 931 | + Column( | |
| 932 | + modifier = Modifier.fillMaxWidth() | |
| 933 | + .heightIn(max = 180.dp) | |
| 934 | + .background(MaterialTheme.colorScheme.surfaceVariant) | |
| 935 | + .padding(8.dp) | |
| 936 | + .verticalScroll(rememberScrollState()), | |
| 937 | + verticalArrangement = Arrangement.spacedBy(1.dp), | |
| 938 | + ) { | |
| 939 | + if (runLog.isEmpty()) { | |
| 940 | + Text("还没有执行记录(worker 跑过一次后出现)。", style = MaterialTheme.typography.labelSmall) | |
| 941 | + } | |
| 942 | + runLog.asReversed().forEach { r -> | |
| 943 | + val tag = if (r.trigger == "manual") "手动" else "自动" | |
| 944 | + Text( | |
| 945 | + "${RUN_FMT.format(Date(r.ts))} $tag · ${r.total}会话→${r.made}篇" + | |
| 946 | + (if (r.skipped > 0) " (跳过${r.skipped})" else "") + | |
| 947 | + " · ${r.secs}s · ${r.backend}", | |
| 948 | + style = MaterialTheme.typography.labelSmall, | |
| 949 | + ) | |
| 950 | + } | |
| 951 | + } | |
| 952 | + } | |
| 912 | 953 | Spacer(Modifier.height(8.dp)) |
| 913 | 954 | |
| 914 | 955 | if (summaries.isEmpty()) { |
| ... | ... | @@ -1276,6 +1317,7 @@ private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) |
| 1276 | 1317 | private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) |
| 1277 | 1318 | private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) |
| 1278 | 1319 | private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US) |
| 1320 | +private val RUN_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) | |
| 1279 | 1321 | |
| 1280 | 1322 | private suspend fun enrollSpeakerForSegment(repo: Repository, segmentId: Long, name: String) { |
| 1281 | 1323 | withContext(Dispatchers.IO) { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryRunLog.kt
0 → 100644
| 1 | +package com.lemon.lifelog.summary | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import kotlinx.coroutines.flow.MutableStateFlow | |
| 5 | +import kotlinx.coroutines.flow.StateFlow | |
| 6 | +import org.json.JSONArray | |
| 7 | +import org.json.JSONObject | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 持久化的纪要 worker 执行记录:每次 doWork 跑完记一行,存 lifelog_prefs(JSON)。 | |
| 11 | + * 让用户在纪要 tab 看到「几点几分跑了、出了几篇」,确认 periodic 在正常运行 | |
| 12 | + * (即使 0 篇——比如会话还没结束——也记一行,证明 worker 活着)。 | |
| 13 | + * | |
| 14 | + * worker 与 UI 同进程,singleton 共享;[runs] StateFlow 供 UI 实时刷新,prefs 保证重启不丢。 | |
| 15 | + */ | |
| 16 | +object SummaryRunLog { | |
| 17 | + private const val PREFS = "lifelog_prefs" | |
| 18 | + private const val KEY = "summary_runs" | |
| 19 | + private const val MAX = 50 | |
| 20 | + | |
| 21 | + data class Run( | |
| 22 | + val ts: Long, | |
| 23 | + val made: Int, | |
| 24 | + val skipped: Int, | |
| 25 | + val total: Int, | |
| 26 | + val secs: Int, | |
| 27 | + val backend: String, | |
| 28 | + val trigger: String, // "auto"(periodic) | "manual" | |
| 29 | + ) | |
| 30 | + | |
| 31 | + private val _runs = MutableStateFlow<List<Run>>(emptyList()) | |
| 32 | + val runs: StateFlow<List<Run>> = _runs | |
| 33 | + | |
| 34 | + /** UI 启动时调,从 prefs 载入历史。 */ | |
| 35 | + fun load(ctx: Context) { | |
| 36 | + if (_runs.value.isEmpty()) _runs.value = read(ctx) | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Synchronized | |
| 40 | + fun add(ctx: Context, run: Run) { | |
| 41 | + val list = (read(ctx) + run).takeLast(MAX) | |
| 42 | + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() | |
| 43 | + .putString(KEY, encode(list)).apply() | |
| 44 | + _runs.value = list | |
| 45 | + } | |
| 46 | + | |
| 47 | + private fun read(ctx: Context): List<Run> { | |
| 48 | + val s = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null) ?: return emptyList() | |
| 49 | + return try { | |
| 50 | + val arr = JSONArray(s) | |
| 51 | + (0 until arr.length()).map { i -> | |
| 52 | + val o = arr.getJSONObject(i) | |
| 53 | + Run( | |
| 54 | + o.getLong("ts"), o.getInt("made"), o.getInt("skipped"), o.getInt("total"), | |
| 55 | + o.getInt("secs"), o.optString("backend"), o.optString("trigger"), | |
| 56 | + ) | |
| 57 | + } | |
| 58 | + } catch (t: Throwable) { | |
| 59 | + emptyList() | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + private fun encode(list: List<Run>): String { | |
| 64 | + val arr = JSONArray() | |
| 65 | + list.forEach { r -> | |
| 66 | + arr.put( | |
| 67 | + JSONObject().apply { | |
| 68 | + put("ts", r.ts); put("made", r.made); put("skipped", r.skipped) | |
| 69 | + put("total", r.total); put("secs", r.secs) | |
| 70 | + put("backend", r.backend); put("trigger", r.trigger) | |
| 71 | + }, | |
| 72 | + ) | |
| 73 | + } | |
| 74 | + return arr.toString() | |
| 75 | + } | |
| 76 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt
| ... | ... | @@ -41,6 +41,7 @@ object SummaryScheduler { |
| 41 | 41 | .getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN) |
| 42 | 42 | val data = Data.Builder() |
| 43 | 43 | .putLong(SummaryWorker.INPUT_GAP_MS, gapMin * 60_000L) |
| 44 | + .putString(SummaryWorker.INPUT_TRIGGER, "manual") | |
| 44 | 45 | .build() |
| 45 | 46 | val request = OneTimeWorkRequestBuilder<SummaryWorker>() |
| 46 | 47 | .setInputData(data) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
| ... | ... | @@ -43,6 +43,7 @@ class SummaryWorker( |
| 43 | 43 | |
| 44 | 44 | const val INPUT_NOW_TS = "now_ts" // 覆盖 now(测试 / 手动) |
| 45 | 45 | const val INPUT_GAP_MS = "gap_ms" // 覆盖会话间隔(手动跑用当前设置) |
| 46 | + const val INPUT_TRIGGER = "trigger" // "manual"(现在跑一次);periodic 不传 = "auto" | |
| 46 | 47 | |
| 47 | 48 | const val PREFS = "lifelog_prefs" |
| 48 | 49 | const val KEY_GAP_MIN = "session_gap_min" |
| ... | ... | @@ -85,6 +86,8 @@ class SummaryWorker( |
| 85 | 86 | private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) |
| 86 | 87 | private val shortFmt = SimpleDateFormat("MM-dd HH:mm", Locale.US) |
| 87 | 88 | |
| 89 | + private fun secs(start: Long) = ((System.currentTimeMillis() - start) / 1000).toInt() | |
| 90 | + | |
| 88 | 91 | override suspend fun doWork(): Result { |
| 89 | 92 | if (!isRunning.compareAndSet(false, true)) { |
| 90 | 93 | Log.i(TAG, "another run in progress, skip") |
| ... | ... | @@ -110,12 +113,14 @@ class SummaryWorker( |
| 110 | 113 | applicationContext, |
| 111 | 114 | prefs.getString(KEY_BACKEND, com.lemon.lifelog.chat.ChatConfig.ID_2B), |
| 112 | 115 | ) |
| 116 | + val trigger = inputData.getString(INPUT_TRIGGER) ?: "auto" | |
| 113 | 117 | SummaryProgress.start("开始 · 后端 ${target.label} · 间隔 ${gapMs / 60_000}min") |
| 114 | 118 | |
| 115 | 119 | val repo = Repository(applicationContext) |
| 116 | 120 | val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs } |
| 117 | 121 | if (segments.isEmpty()) { |
| 118 | 122 | SummaryProgress.finish("没有录音段,结束") |
| 123 | + SummaryRunLog.add(applicationContext, SummaryRunLog.Run(now, 0, 0, 0, secs(runStart), target.label, trigger)) | |
| 119 | 124 | Log.i(TAG, "no segments"); return Result.success() |
| 120 | 125 | } |
| 121 | 126 | |
| ... | ... | @@ -175,6 +180,7 @@ class SummaryWorker( |
| 175 | 180 | } |
| 176 | 181 | |
| 177 | 182 | SummaryProgress.finish("完成:$made 篇生成,$skipped 个会话跳过(共 ${sessions.size})") |
| 183 | + SummaryRunLog.add(applicationContext, SummaryRunLog.Run(now, made, skipped, sessions.size, secs(runStart), target.label, trigger)) | |
| 178 | 184 | Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)") |
| 179 | 185 | // 不 retry:失败/未处理的会话下一轮 periodic 或手动「现在跑一次」会重新捡, |
| 180 | 186 | // 避免 502 后 WorkManager 死循环 hammering。 | ... | ... |