Commit faf5436b5d5df8c17e4a4bb2b85b3bdf34cac287
1 parent
3f312297
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,6 +84,7 @@ import com.lemon.lifelog.asr.transcribeWithStats | ||
| 84 | import com.lemon.lifelog.audio.SegmentPlayer | 84 | import com.lemon.lifelog.audio.SegmentPlayer |
| 85 | import com.lemon.lifelog.chat.ChatConfig | 85 | import com.lemon.lifelog.chat.ChatConfig |
| 86 | import com.lemon.lifelog.summary.SummaryProgress | 86 | import com.lemon.lifelog.summary.SummaryProgress |
| 87 | +import com.lemon.lifelog.summary.SummaryRunLog | ||
| 87 | import com.lemon.lifelog.data.Repository | 88 | import com.lemon.lifelog.data.Repository |
| 88 | import com.lemon.lifelog.data.SegmentRow | 89 | import com.lemon.lifelog.data.SegmentRow |
| 89 | import com.lemon.lifelog.data.SpeakerProfile | 90 | import com.lemon.lifelog.data.SpeakerProfile |
| @@ -840,6 +841,9 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon | @@ -840,6 +841,9 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon | ||
| 840 | val backendTargets = remember { ChatConfig.targets(activity) } | 841 | val backendTargets = remember { ChatConfig.targets(activity) } |
| 841 | val running by SummaryProgress.running.collectAsState() | 842 | val running by SummaryProgress.running.collectAsState() |
| 842 | val progressLog by SummaryProgress.log.collectAsState() | 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 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { | 848 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { |
| 845 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | 849 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| @@ -909,6 +913,43 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon | @@ -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 | Spacer(Modifier.height(8.dp)) | 953 | Spacer(Modifier.height(8.dp)) |
| 913 | 954 | ||
| 914 | if (summaries.isEmpty()) { | 955 | if (summaries.isEmpty()) { |
| @@ -1276,6 +1317,7 @@ private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) | @@ -1276,6 +1317,7 @@ private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) | ||
| 1276 | private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) | 1317 | private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) |
| 1277 | private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) | 1318 | private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) |
| 1278 | private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US) | 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 | private suspend fun enrollSpeakerForSegment(repo: Repository, segmentId: Long, name: String) { | 1322 | private suspend fun enrollSpeakerForSegment(repo: Repository, segmentId: Long, name: String) { |
| 1281 | withContext(Dispatchers.IO) { | 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,6 +41,7 @@ object SummaryScheduler { | ||
| 41 | .getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN) | 41 | .getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN) |
| 42 | val data = Data.Builder() | 42 | val data = Data.Builder() |
| 43 | .putLong(SummaryWorker.INPUT_GAP_MS, gapMin * 60_000L) | 43 | .putLong(SummaryWorker.INPUT_GAP_MS, gapMin * 60_000L) |
| 44 | + .putString(SummaryWorker.INPUT_TRIGGER, "manual") | ||
| 44 | .build() | 45 | .build() |
| 45 | val request = OneTimeWorkRequestBuilder<SummaryWorker>() | 46 | val request = OneTimeWorkRequestBuilder<SummaryWorker>() |
| 46 | .setInputData(data) | 47 | .setInputData(data) |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
| @@ -43,6 +43,7 @@ class SummaryWorker( | @@ -43,6 +43,7 @@ class SummaryWorker( | ||
| 43 | 43 | ||
| 44 | const val INPUT_NOW_TS = "now_ts" // 覆盖 now(测试 / 手动) | 44 | const val INPUT_NOW_TS = "now_ts" // 覆盖 now(测试 / 手动) |
| 45 | const val INPUT_GAP_MS = "gap_ms" // 覆盖会话间隔(手动跑用当前设置) | 45 | const val INPUT_GAP_MS = "gap_ms" // 覆盖会话间隔(手动跑用当前设置) |
| 46 | + const val INPUT_TRIGGER = "trigger" // "manual"(现在跑一次);periodic 不传 = "auto" | ||
| 46 | 47 | ||
| 47 | const val PREFS = "lifelog_prefs" | 48 | const val PREFS = "lifelog_prefs" |
| 48 | const val KEY_GAP_MIN = "session_gap_min" | 49 | const val KEY_GAP_MIN = "session_gap_min" |
| @@ -85,6 +86,8 @@ class SummaryWorker( | @@ -85,6 +86,8 @@ class SummaryWorker( | ||
| 85 | private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) | 86 | private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) |
| 86 | private val shortFmt = SimpleDateFormat("MM-dd HH:mm", Locale.US) | 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 | override suspend fun doWork(): Result { | 91 | override suspend fun doWork(): Result { |
| 89 | if (!isRunning.compareAndSet(false, true)) { | 92 | if (!isRunning.compareAndSet(false, true)) { |
| 90 | Log.i(TAG, "another run in progress, skip") | 93 | Log.i(TAG, "another run in progress, skip") |
| @@ -110,12 +113,14 @@ class SummaryWorker( | @@ -110,12 +113,14 @@ class SummaryWorker( | ||
| 110 | applicationContext, | 113 | applicationContext, |
| 111 | prefs.getString(KEY_BACKEND, com.lemon.lifelog.chat.ChatConfig.ID_2B), | 114 | prefs.getString(KEY_BACKEND, com.lemon.lifelog.chat.ChatConfig.ID_2B), |
| 112 | ) | 115 | ) |
| 116 | + val trigger = inputData.getString(INPUT_TRIGGER) ?: "auto" | ||
| 113 | SummaryProgress.start("开始 · 后端 ${target.label} · 间隔 ${gapMs / 60_000}min") | 117 | SummaryProgress.start("开始 · 后端 ${target.label} · 间隔 ${gapMs / 60_000}min") |
| 114 | 118 | ||
| 115 | val repo = Repository(applicationContext) | 119 | val repo = Repository(applicationContext) |
| 116 | val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs } | 120 | val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs } |
| 117 | if (segments.isEmpty()) { | 121 | if (segments.isEmpty()) { |
| 118 | SummaryProgress.finish("没有录音段,结束") | 122 | SummaryProgress.finish("没有录音段,结束") |
| 123 | + SummaryRunLog.add(applicationContext, SummaryRunLog.Run(now, 0, 0, 0, secs(runStart), target.label, trigger)) | ||
| 119 | Log.i(TAG, "no segments"); return Result.success() | 124 | Log.i(TAG, "no segments"); return Result.success() |
| 120 | } | 125 | } |
| 121 | 126 | ||
| @@ -175,6 +180,7 @@ class SummaryWorker( | @@ -175,6 +180,7 @@ class SummaryWorker( | ||
| 175 | } | 180 | } |
| 176 | 181 | ||
| 177 | SummaryProgress.finish("完成:$made 篇生成,$skipped 个会话跳过(共 ${sessions.size})") | 182 | SummaryProgress.finish("完成:$made 篇生成,$skipped 个会话跳过(共 ${sessions.size})") |
| 183 | + SummaryRunLog.add(applicationContext, SummaryRunLog.Run(now, made, skipped, sessions.size, secs(runStart), target.label, trigger)) | ||
| 178 | Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)") | 184 | Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)") |
| 179 | // 不 retry:失败/未处理的会话下一轮 periodic 或手动「现在跑一次」会重新捡, | 185 | // 不 retry:失败/未处理的会话下一轮 periodic 或手动「现在跑一次」会重新捡, |
| 180 | // 避免 502 后 WorkManager 死循环 hammering。 | 186 | // 避免 502 后 WorkManager 死循环 hammering。 |