Commit e5d6614f1025943637af13eb8c0c8c9da21374b5

Authored by yibo
1 parent ddf5e3a8

feat(lifelog/summary): 纪要后端可选(2B/4B/Hermes) + 实时进度 console

- SummaryWorker: 后端从 SharedPreferences 读 ChatTarget.id(local-2b/4b/hermes),
  经 ChatConfig 解析端点/model(与对话共用配置),不再写死 2B。
- SummaryProgress:新增 worker→UI 进度 singleton(同进程 StateFlow),
  worker 每步发 line(开始/会话生成中/✅完成 Ns/❌失败/跳过统计)。
- SummariesTab:加「纪要模型」下拉(2B/4B/Hermes,存 prefs)+ 进度 console
  (滚动框,最新在上)+「现在跑一次」运行中转圈禁用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -24,7 +24,10 @@ import androidx.compose.foundation.layout.Spacer
24 24 import androidx.compose.foundation.layout.fillMaxSize
25 25 import androidx.compose.foundation.layout.fillMaxWidth
26 26 import androidx.compose.foundation.layout.height
  27 +import androidx.compose.foundation.layout.heightIn
27 28 import androidx.compose.foundation.layout.padding
  29 +import androidx.compose.foundation.layout.size
  30 +import androidx.compose.foundation.layout.width
28 31 import androidx.compose.foundation.lazy.LazyColumn
29 32 import androidx.compose.foundation.lazy.items
30 33 import androidx.compose.foundation.lazy.rememberLazyListState
... ... @@ -33,6 +36,7 @@ import androidx.compose.foundation.verticalScroll
33 36 import androidx.compose.material3.AlertDialog
34 37 import androidx.compose.material3.Button
35 38 import androidx.compose.material3.Card
  39 +import androidx.compose.material3.CircularProgressIndicator
36 40 import androidx.compose.material3.DropdownMenu
37 41 import androidx.compose.material3.DropdownMenuItem
38 42 import androidx.compose.material3.ExperimentalMaterial3Api
... ... @@ -77,6 +81,8 @@ import com.lemon.lifelog.asr.SpeakerClusterer
77 81 import com.lemon.lifelog.asr.XfyunIatClient
78 82 import com.lemon.lifelog.asr.transcribeWithStats
79 83 import com.lemon.lifelog.audio.SegmentPlayer
  84 +import com.lemon.lifelog.chat.ChatConfig
  85 +import com.lemon.lifelog.summary.SummaryProgress
80 86 import com.lemon.lifelog.data.Repository
81 87 import com.lemon.lifelog.data.SegmentRow
82 88 import com.lemon.lifelog.data.SpeakerProfile
... ... @@ -818,11 +824,22 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon
818 824 val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) }
819 825 var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) }
820 826 var gapMenu by remember { mutableStateOf(false) }
  827 + var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) }
  828 + var backendMenu by remember { mutableStateOf(false) }
  829 + val backendTargets = remember { ChatConfig.targets(activity) }
  830 + val running by SummaryProgress.running.collectAsState()
  831 + val progressLog by SummaryProgress.log.collectAsState()
821 832  
822 833 Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
823 834 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
824 835 Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium)
825   - Button(onClick = { SummaryScheduler.runNow(activity) }) { Text("现在跑一次") }
  836 + Button(onClick = { SummaryScheduler.runNow(activity) }, enabled = !running) {
  837 + if (running) {
  838 + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
  839 + Spacer(Modifier.width(6.dp))
  840 + }
  841 + Text(if (running) "运行中…" else "现在跑一次")
  842 + }
826 843 }
827 844  
828 845 // 会话切分间隔设置。
... ... @@ -842,10 +859,45 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon
842 859 }
843 860 Text("没声音就算另一场", style = MaterialTheme.typography.bodySmall)
844 861 }
  862 +
  863 + // 纪要模型选择(本地 2B / 4B / Hermes)。
  864 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  865 + Text("纪要模型:", style = MaterialTheme.typography.bodySmall)
  866 + Box {
  867 + val curLabel = backendTargets.firstOrNull { it.id == backendId }?.label ?: "本地 2B"
  868 + FilterChip(selected = true, onClick = { backendMenu = true }, label = { Text(curLabel) })
  869 + DropdownMenu(expanded = backendMenu, onDismissRequest = { backendMenu = false }) {
  870 + backendTargets.forEach { t ->
  871 + DropdownMenuItem(text = { Text(t.label) }, onClick = {
  872 + backendId = t.id
  873 + prefs.edit().putString(SummaryWorker.KEY_BACKEND, t.id).apply()
  874 + backendMenu = false
  875 + })
  876 + }
  877 + }
  878 + }
  879 + }
845 880 Text(
846   - "按时间自动切会话,每段连续录音单独成一篇纪要。需要 hermes 在 127.0.0.1:8642 跑着。",
  881 + "按时间自动切会话,每段连续录音单独成一篇纪要。本地 2B/4B 走 llama-swap 8080,Hermes 走 8642。",
847 882 style = MaterialTheme.typography.bodySmall,
848 883 )
  884 +
  885 + // 进度 console(worker 实时进度)。
  886 + if (progressLog.isNotEmpty()) {
  887 + Spacer(Modifier.height(6.dp))
  888 + Column(
  889 + modifier = Modifier.fillMaxWidth()
  890 + .heightIn(max = 150.dp)
  891 + .background(MaterialTheme.colorScheme.surfaceVariant)
  892 + .padding(8.dp)
  893 + .verticalScroll(rememberScrollState()),
  894 + verticalArrangement = Arrangement.spacedBy(1.dp),
  895 + ) {
  896 + progressLog.asReversed().forEach { ln ->
  897 + Text(ln, style = MaterialTheme.typography.labelSmall)
  898 + }
  899 + }
  900 + }
849 901 Spacer(Modifier.height(8.dp))
850 902  
851 903 if (summaries.isEmpty()) {
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryProgress.kt 0 → 100644
  1 +package com.lemon.lifelog.summary
  2 +
  3 +import kotlinx.coroutines.flow.MutableStateFlow
  4 +import kotlinx.coroutines.flow.StateFlow
  5 +import java.text.SimpleDateFormat
  6 +import java.util.Date
  7 +import java.util.Locale
  8 +
  9 +/**
  10 + * SummaryWorker → UI 的实时进度。worker 与 UI 同进程,singleton 共享即可
  11 + * (类似 RecorderState)。UI 在纪要 tab 显示一个滚动 console + 运行指示。
  12 + */
  13 +object SummaryProgress {
  14 + private val fmt = SimpleDateFormat("HH:mm:ss", Locale.US)
  15 +
  16 + private val _running = MutableStateFlow(false)
  17 + val running: StateFlow<Boolean> = _running
  18 +
  19 + private val _log = MutableStateFlow<List<String>>(emptyList())
  20 + val log: StateFlow<List<String>> = _log
  21 +
  22 + fun start(header: String) {
  23 + _running.value = true
  24 + _log.value = listOf(stamp(header))
  25 + }
  26 +
  27 + fun line(msg: String) {
  28 + _log.value = (_log.value + stamp(msg)).takeLast(80)
  29 + }
  30 +
  31 + fun finish(msg: String) {
  32 + _log.value = (_log.value + stamp(msg)).takeLast(80)
  33 + _running.value = false
  34 + }
  35 +
  36 + private fun stamp(msg: String) = "${fmt.format(Date())} $msg"
  37 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
... ... @@ -47,10 +47,9 @@ class SummaryWorker(
47 47 const val PREFS = "lifelog_prefs"
48 48 const val KEY_GAP_MIN = "session_gap_min"
49 49  
50   - /** 纪要后端:本地 llama-swap 的 2B(与聊天共用 8080,按 model 名热切换)。
51   - * 独立于 keys.properties(那个留给聊天的 Hermes 选项)。 */
52   - const val LLM_BASE_URL = "http://127.0.0.1:8080"
53   - const val LLM_MODEL = "2b"
  50 + /** 纪要后端:存 ChatTarget.id(local-2b / local-4b / hermes),UI 可选,默认本地 2B。
  51 + * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */
  52 + const val KEY_BACKEND = "summary_backend"
54 53  
55 54 /** 纯函数:把按时间排好序的段切成若干会话。 */
56 55 fun groupSessions(segments: List<Segment>, gapMs: Long): List<List<Segment>> {
... ... @@ -69,6 +68,7 @@ class SummaryWorker(
69 68  
70 69 private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.US)
71 70 private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
  71 + private val shortFmt = SimpleDateFormat("MM-dd HH:mm", Locale.US)
72 72  
73 73 override suspend fun doWork(): Result {
74 74 val now = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis())
... ... @@ -76,38 +76,52 @@ class SummaryWorker(
76 76 val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 }
77 77 ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L)
78 78  
  79 + // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。
  80 + val target = com.lemon.lifelog.chat.ChatConfig.targetById(
  81 + applicationContext,
  82 + prefs.getString(KEY_BACKEND, com.lemon.lifelog.chat.ChatConfig.ID_2B),
  83 + )
  84 + SummaryProgress.start("开始 · 后端 ${target.label} · 间隔 ${gapMs / 60_000}min")
  85 +
79 86 val repo = Repository(applicationContext)
80 87 val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs }
81 88 if (segments.isEmpty()) {
  89 + SummaryProgress.finish("没有录音段,结束")
82 90 Log.i(TAG, "no segments"); return Result.success()
83 91 }
84 92  
85 93 val sessions = groupSessions(segments, gapMs)
86 94 val existing = repo.summaries.all()
87 95 val speakerNames = repo.speakers.all().associate { it.id to it.name }
  96 + SummaryProgress.line("切出 ${sessions.size} 个会话,逐个检查…")
88 97  
89 98 var made = 0
  99 + var skipped = 0
90 100 var anyFailed = false
91 101  
92 102 for (session in sessions) {
93 103 val start = session.first().startTs
94 104 val end = session.last().endTs
  105 + val label = "${shortFmt.format(Date(start))}–${shortFmt.format(Date(end))} (${session.size}段)"
95 106  
96 107 // 未结束的会话不动(可能还会有新段并入)。
97   - if (now - end <= gapMs) continue
  108 + if (now - end <= gapMs) { skipped++; continue }
98 109 // 太短跳过。
99   - if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) continue
  110 + if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) { skipped++; continue }
100 111 // 已总结过(时间范围重叠)跳过。
101   - if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) continue
  112 + if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue }
102 113  
103 114 val userPrompt = buildPrompt(repo, session, speakerNames, start, end)
104   - if (userPrompt == null) continue // 整段无有效转写
  115 + if (userPrompt == null) { skipped++; continue } // 整段无有效转写
105 116  
106   - val client = HermesClient(baseUrl = LLM_BASE_URL, apiKey = "", model = LLM_MODEL)
  117 + SummaryProgress.line("⏳ $label 生成中…")
  118 + val t0 = System.currentTimeMillis()
  119 + val client = HermesClient(baseUrl = target.baseUrl, apiKey = target.apiKey, model = target.model)
107 120 val resp = try {
108 121 client.chat(SYSTEM_PROMPT, userPrompt)
109 122 } catch (t: Throwable) {
110   - Log.w(TAG, "hermes call failed for session ${rangeFmt.format(Date(start))}: ${t.message}")
  123 + SummaryProgress.line("❌ $label 失败:${t.message?.take(50)}")
  124 + Log.w(TAG, "llm call failed for session ${rangeFmt.format(Date(start))}: ${t.message}")
111 125 anyFailed = true
112 126 continue
113 127 }
... ... @@ -122,13 +136,11 @@ class SummaryWorker(
122 136 ),
123 137 )
124 138 made++
125   - Log.i(
126   - TAG,
127   - "summary saved ${rangeFmt.format(Date(start))}–${rangeFmt.format(Date(end))} " +
128   - "segments=${session.size} speech=${session.sumOf { it.durationMs } / 1000}s",
129   - )
  139 + SummaryProgress.line("✅ $label 完成 (${(System.currentTimeMillis() - t0) / 1000}s)")
  140 + Log.i(TAG, "summary saved ${rangeFmt.format(Date(start))} segments=${session.size}")
130 141 }
131 142  
  143 + SummaryProgress.finish("完成:$made 篇生成,$skipped 个会话跳过(共 ${sessions.size})")
132 144 Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)")
133 145 return if (anyFailed && made == 0) Result.retry() else Result.success()
134 146 }
... ...