From e5d6614f1025943637af13eb8c0c8c9da21374b5 Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 4 Jun 2026 18:47:37 +0800 Subject: [PATCH] feat(lifelog/summary): 纪要后端可选(2B/4B/Hermes) + 实时进度 console --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryProgress.kt | 37 +++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt | 42 +++++++++++++++++++++++++++--------------- 3 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryProgress.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index 33cb4e1..022451c 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -24,7 +24,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -33,6 +36,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -77,6 +81,8 @@ import com.lemon.lifelog.asr.SpeakerClusterer import com.lemon.lifelog.asr.XfyunIatClient import com.lemon.lifelog.asr.transcribeWithStats import com.lemon.lifelog.audio.SegmentPlayer +import com.lemon.lifelog.chat.ChatConfig +import com.lemon.lifelog.summary.SummaryProgress import com.lemon.lifelog.data.Repository import com.lemon.lifelog.data.SegmentRow import com.lemon.lifelog.data.SpeakerProfile @@ -818,11 +824,22 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) } var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) } var gapMenu by remember { mutableStateOf(false) } + var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) } + var backendMenu by remember { mutableStateOf(false) } + val backendTargets = remember { ChatConfig.targets(activity) } + val running by SummaryProgress.running.collectAsState() + val progressLog by SummaryProgress.log.collectAsState() Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium) - Button(onClick = { SummaryScheduler.runNow(activity) }) { Text("现在跑一次") } + Button(onClick = { SummaryScheduler.runNow(activity) }, enabled = !running) { + if (running) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(6.dp)) + } + Text(if (running) "运行中…" else "现在跑一次") + } } // 会话切分间隔设置。 @@ -842,10 +859,45 @@ private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Lon } Text("没声音就算另一场", style = MaterialTheme.typography.bodySmall) } + + // 纪要模型选择(本地 2B / 4B / Hermes)。 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("纪要模型:", style = MaterialTheme.typography.bodySmall) + Box { + val curLabel = backendTargets.firstOrNull { it.id == backendId }?.label ?: "本地 2B" + FilterChip(selected = true, onClick = { backendMenu = true }, label = { Text(curLabel) }) + DropdownMenu(expanded = backendMenu, onDismissRequest = { backendMenu = false }) { + backendTargets.forEach { t -> + DropdownMenuItem(text = { Text(t.label) }, onClick = { + backendId = t.id + prefs.edit().putString(SummaryWorker.KEY_BACKEND, t.id).apply() + backendMenu = false + }) + } + } + } + } Text( - "按时间自动切会话,每段连续录音单独成一篇纪要。需要 hermes 在 127.0.0.1:8642 跑着。", + "按时间自动切会话,每段连续录音单独成一篇纪要。本地 2B/4B 走 llama-swap 8080,Hermes 走 8642。", style = MaterialTheme.typography.bodySmall, ) + + // 进度 console(worker 实时进度)。 + if (progressLog.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Column( + modifier = Modifier.fillMaxWidth() + .heightIn(max = 150.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(8.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + progressLog.asReversed().forEach { ln -> + Text(ln, style = MaterialTheme.typography.labelSmall) + } + } + } Spacer(Modifier.height(8.dp)) if (summaries.isEmpty()) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryProgress.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryProgress.kt new file mode 100644 index 0000000..23426af --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryProgress.kt @@ -0,0 +1,37 @@ +package com.lemon.lifelog.summary + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * SummaryWorker → UI 的实时进度。worker 与 UI 同进程,singleton 共享即可 + * (类似 RecorderState)。UI 在纪要 tab 显示一个滚动 console + 运行指示。 + */ +object SummaryProgress { + private val fmt = SimpleDateFormat("HH:mm:ss", Locale.US) + + private val _running = MutableStateFlow(false) + val running: StateFlow = _running + + private val _log = MutableStateFlow>(emptyList()) + val log: StateFlow> = _log + + fun start(header: String) { + _running.value = true + _log.value = listOf(stamp(header)) + } + + fun line(msg: String) { + _log.value = (_log.value + stamp(msg)).takeLast(80) + } + + fun finish(msg: String) { + _log.value = (_log.value + stamp(msg)).takeLast(80) + _running.value = false + } + + private fun stamp(msg: String) = "${fmt.format(Date())} $msg" +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt index d26f64e..77f1da9 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt @@ -47,10 +47,9 @@ class SummaryWorker( const val PREFS = "lifelog_prefs" const val KEY_GAP_MIN = "session_gap_min" - /** 纪要后端:本地 llama-swap 的 2B(与聊天共用 8080,按 model 名热切换)。 - * 独立于 keys.properties(那个留给聊天的 Hermes 选项)。 */ - const val LLM_BASE_URL = "http://127.0.0.1:8080" - const val LLM_MODEL = "2b" + /** 纪要后端:存 ChatTarget.id(local-2b / local-4b / hermes),UI 可选,默认本地 2B。 + * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */ + const val KEY_BACKEND = "summary_backend" /** 纯函数:把按时间排好序的段切成若干会话。 */ fun groupSessions(segments: List, gapMs: Long): List> { @@ -69,6 +68,7 @@ class SummaryWorker( private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.US) private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) + private val shortFmt = SimpleDateFormat("MM-dd HH:mm", Locale.US) override suspend fun doWork(): Result { val now = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis()) @@ -76,38 +76,52 @@ class SummaryWorker( val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L) + // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。 + val target = com.lemon.lifelog.chat.ChatConfig.targetById( + applicationContext, + prefs.getString(KEY_BACKEND, com.lemon.lifelog.chat.ChatConfig.ID_2B), + ) + SummaryProgress.start("开始 · 后端 ${target.label} · 间隔 ${gapMs / 60_000}min") + val repo = Repository(applicationContext) val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs } if (segments.isEmpty()) { + SummaryProgress.finish("没有录音段,结束") Log.i(TAG, "no segments"); return Result.success() } val sessions = groupSessions(segments, gapMs) val existing = repo.summaries.all() val speakerNames = repo.speakers.all().associate { it.id to it.name } + SummaryProgress.line("切出 ${sessions.size} 个会话,逐个检查…") var made = 0 + var skipped = 0 var anyFailed = false for (session in sessions) { val start = session.first().startTs val end = session.last().endTs + val label = "${shortFmt.format(Date(start))}–${shortFmt.format(Date(end))} (${session.size}段)" // 未结束的会话不动(可能还会有新段并入)。 - if (now - end <= gapMs) continue + if (now - end <= gapMs) { skipped++; continue } // 太短跳过。 - if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) continue + if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) { skipped++; continue } // 已总结过(时间范围重叠)跳过。 - if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) continue + if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue } val userPrompt = buildPrompt(repo, session, speakerNames, start, end) - if (userPrompt == null) continue // 整段无有效转写 + if (userPrompt == null) { skipped++; continue } // 整段无有效转写 - val client = HermesClient(baseUrl = LLM_BASE_URL, apiKey = "", model = LLM_MODEL) + SummaryProgress.line("⏳ $label 生成中…") + val t0 = System.currentTimeMillis() + val client = HermesClient(baseUrl = target.baseUrl, apiKey = target.apiKey, model = target.model) val resp = try { client.chat(SYSTEM_PROMPT, userPrompt) } catch (t: Throwable) { - Log.w(TAG, "hermes call failed for session ${rangeFmt.format(Date(start))}: ${t.message}") + SummaryProgress.line("❌ $label 失败:${t.message?.take(50)}") + Log.w(TAG, "llm call failed for session ${rangeFmt.format(Date(start))}: ${t.message}") anyFailed = true continue } @@ -122,13 +136,11 @@ class SummaryWorker( ), ) made++ - Log.i( - TAG, - "summary saved ${rangeFmt.format(Date(start))}–${rangeFmt.format(Date(end))} " + - "segments=${session.size} speech=${session.sumOf { it.durationMs } / 1000}s", - ) + SummaryProgress.line("✅ $label 完成 (${(System.currentTimeMillis() - t0) / 1000}s)") + Log.i(TAG, "summary saved ${rangeFmt.format(Date(start))} segments=${session.size}") } + SummaryProgress.finish("完成:$made 篇生成,$skipped 个会话跳过(共 ${sessions.size})") Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)") return if (anyFailed && made == 0) Result.retry() else Result.success() } -- libgit2 0.21.4