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,7 +24,10 @@ import androidx.compose.foundation.layout.Spacer
24 import androidx.compose.foundation.layout.fillMaxSize 24 import androidx.compose.foundation.layout.fillMaxSize
25 import androidx.compose.foundation.layout.fillMaxWidth 25 import androidx.compose.foundation.layout.fillMaxWidth
26 import androidx.compose.foundation.layout.height 26 import androidx.compose.foundation.layout.height
  27 +import androidx.compose.foundation.layout.heightIn
27 import androidx.compose.foundation.layout.padding 28 import androidx.compose.foundation.layout.padding
  29 +import androidx.compose.foundation.layout.size
  30 +import androidx.compose.foundation.layout.width
28 import androidx.compose.foundation.lazy.LazyColumn 31 import androidx.compose.foundation.lazy.LazyColumn
29 import androidx.compose.foundation.lazy.items 32 import androidx.compose.foundation.lazy.items
30 import androidx.compose.foundation.lazy.rememberLazyListState 33 import androidx.compose.foundation.lazy.rememberLazyListState
@@ -33,6 +36,7 @@ import androidx.compose.foundation.verticalScroll @@ -33,6 +36,7 @@ import androidx.compose.foundation.verticalScroll
33 import androidx.compose.material3.AlertDialog 36 import androidx.compose.material3.AlertDialog
34 import androidx.compose.material3.Button 37 import androidx.compose.material3.Button
35 import androidx.compose.material3.Card 38 import androidx.compose.material3.Card
  39 +import androidx.compose.material3.CircularProgressIndicator
36 import androidx.compose.material3.DropdownMenu 40 import androidx.compose.material3.DropdownMenu
37 import androidx.compose.material3.DropdownMenuItem 41 import androidx.compose.material3.DropdownMenuItem
38 import androidx.compose.material3.ExperimentalMaterial3Api 42 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -77,6 +81,8 @@ import com.lemon.lifelog.asr.SpeakerClusterer @@ -77,6 +81,8 @@ import com.lemon.lifelog.asr.SpeakerClusterer
77 import com.lemon.lifelog.asr.XfyunIatClient 81 import com.lemon.lifelog.asr.XfyunIatClient
78 import com.lemon.lifelog.asr.transcribeWithStats 82 import com.lemon.lifelog.asr.transcribeWithStats
79 import com.lemon.lifelog.audio.SegmentPlayer 83 import com.lemon.lifelog.audio.SegmentPlayer
  84 +import com.lemon.lifelog.chat.ChatConfig
  85 +import com.lemon.lifelog.summary.SummaryProgress
80 import com.lemon.lifelog.data.Repository 86 import com.lemon.lifelog.data.Repository
81 import com.lemon.lifelog.data.SegmentRow 87 import com.lemon.lifelog.data.SegmentRow
82 import com.lemon.lifelog.data.SpeakerProfile 88 import com.lemon.lifelog.data.SpeakerProfile
@@ -818,11 +824,22 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon @@ -818,11 +824,22 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon
818 val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) } 824 val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) }
819 var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) } 825 var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) }
820 var gapMenu by remember { mutableStateOf(false) } 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 Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { 833 Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
823 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { 834 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
824 Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium) 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,10 +859,45 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon
842 } 859 }
843 Text("没声音就算另一场", style = MaterialTheme.typography.bodySmall) 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 Text( 880 Text(
846 - "按时间自动切会话,每段连续录音单独成一篇纪要。需要 hermes 在 127.0.0.1:8642 跑着。", 881 + "按时间自动切会话,每段连续录音单独成一篇纪要。本地 2B/4B 走 llama-swap 8080,Hermes 走 8642。",
847 style = MaterialTheme.typography.bodySmall, 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 Spacer(Modifier.height(8.dp)) 901 Spacer(Modifier.height(8.dp))
850 902
851 if (summaries.isEmpty()) { 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,10 +47,9 @@ class SummaryWorker(
47 const val PREFS = "lifelog_prefs" 47 const val PREFS = "lifelog_prefs"
48 const val KEY_GAP_MIN = "session_gap_min" 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 fun groupSessions(segments: List<Segment>, gapMs: Long): List<List<Segment>> { 55 fun groupSessions(segments: List<Segment>, gapMs: Long): List<List<Segment>> {
@@ -69,6 +68,7 @@ class SummaryWorker( @@ -69,6 +68,7 @@ class SummaryWorker(
69 68
70 private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.US) 69 private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.US)
71 private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) 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 override suspend fun doWork(): Result { 73 override suspend fun doWork(): Result {
74 val now = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis()) 74 val now = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis())
@@ -76,38 +76,52 @@ class SummaryWorker( @@ -76,38 +76,52 @@ class SummaryWorker(
76 val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } 76 val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 }
77 ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L) 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 val repo = Repository(applicationContext) 86 val repo = Repository(applicationContext)
80 val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs } 87 val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs }
81 if (segments.isEmpty()) { 88 if (segments.isEmpty()) {
  89 + SummaryProgress.finish("没有录音段,结束")
82 Log.i(TAG, "no segments"); return Result.success() 90 Log.i(TAG, "no segments"); return Result.success()
83 } 91 }
84 92
85 val sessions = groupSessions(segments, gapMs) 93 val sessions = groupSessions(segments, gapMs)
86 val existing = repo.summaries.all() 94 val existing = repo.summaries.all()
87 val speakerNames = repo.speakers.all().associate { it.id to it.name } 95 val speakerNames = repo.speakers.all().associate { it.id to it.name }
  96 + SummaryProgress.line("切出 ${sessions.size} 个会话,逐个检查…")
88 97
89 var made = 0 98 var made = 0
  99 + var skipped = 0
90 var anyFailed = false 100 var anyFailed = false
91 101
92 for (session in sessions) { 102 for (session in sessions) {
93 val start = session.first().startTs 103 val start = session.first().startTs
94 val end = session.last().endTs 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 val userPrompt = buildPrompt(repo, session, speakerNames, start, end) 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 val resp = try { 120 val resp = try {
108 client.chat(SYSTEM_PROMPT, userPrompt) 121 client.chat(SYSTEM_PROMPT, userPrompt)
109 } catch (t: Throwable) { 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 anyFailed = true 125 anyFailed = true
112 continue 126 continue
113 } 127 }
@@ -122,13 +136,11 @@ class SummaryWorker( @@ -122,13 +136,11 @@ class SummaryWorker(
122 ), 136 ),
123 ) 137 )
124 made++ 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 Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)") 144 Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)")
133 return if (anyFailed && made == 0) Result.retry() else Result.success() 145 return if (anyFailed && made == 0) Result.retry() else Result.success()
134 } 146 }