Commit d2837118c274d0de2aedc4d8102b4805b5e486d7

Authored by yibo
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)· 上次 HH:mm:ss → M篇」,展开看历次。

验证:手动触发 → prefs 记 {made:0,skipped:9,total:9,trigger:manual} → UI 正常显示。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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(&quot;MM-dd HH:mm:ss&quot;, 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。
... ...