Commit 299e0ef32ea7ae7d8b4708962d8615ef1502e046
1 parent
0dffdeca
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>
Showing
3 changed files
with
118 additions
and
17 deletions
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 | } | ... | ... |