From fd8e772a907e67303bc10d8e1ada10727b4cb343 Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 4 Jun 2026 16:51:36 +0800 Subject: [PATCH] feat(lifelog/chat): 新增「对话」tab — 本地 2B/4B + Hermes 多端点聊天 --- lifelog-recorder/.gitignore | 3 +++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt | 413 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt | 31 ++++++++++++++++++++++++++++--- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt | 3 ++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatClient.kt | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatConfig.kt | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 22 ++++++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 36 ++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 23 +++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt | 1 + 10 files changed, 722 insertions(+), 6 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatClient.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatConfig.kt diff --git a/lifelog-recorder/.gitignore b/lifelog-recorder/.gitignore index beb2169..573ccab 100644 --- a/lifelog-recorder/.gitignore +++ b/lifelog-recorder/.gitignore @@ -15,3 +15,6 @@ keys.properties # Build outputs **/build/ + +# 录音恢复备份(DB 误清后从设备抢救的原始音频,勿入库) +_recovered_segments_*/ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt new file mode 100644 index 0000000..5a9cf6c --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt @@ -0,0 +1,413 @@ +package com.lemon.lifelog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lemon.lifelog.chat.ChatClient +import com.lemon.lifelog.chat.ChatConfig +import com.lemon.lifelog.chat.ChatTarget +import com.lemon.lifelog.data.ChatConversation +import com.lemon.lifelog.data.ChatMessage +import com.lemon.lifelog.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChatTab(repo: Repository) { + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + val client = remember { ChatClient() } + + // 配置刷新计数器:改完端点设置后 bump 一下重新读 targets。 + var configRev by remember { mutableStateOf(0) } + val targets = remember(configRev) { ChatConfig.targets(ctx) } + + val conversations by repo.chats.observeConversations().collectAsState(initial = emptyList()) + + var currentConvId by remember { mutableStateOf(null) } + var selectedTargetId by remember { mutableStateOf(targets.first().id) } + val selectedTarget = remember(selectedTargetId, configRev) { ChatConfig.targetById(ctx, selectedTargetId) } + + // 当前会话的消息流。 + val messages by remember(currentConvId) { + val cid = currentConvId + if (cid == null) kotlinx.coroutines.flow.flowOf(emptyList()) + else repo.chats.observeMessages(cid) + }.collectAsState(initial = emptyList()) + + // 流式中的 assistant 回复(还没落库)。 + var streaming by remember { mutableStateOf(false) } + var streamingText by remember { mutableStateOf("") } + + var showSettings by remember { mutableStateOf(false) } + var input by remember { mutableStateOf("") } + + val drawerState = rememberDrawerState(DrawerValue.Closed) + val listState = rememberLazyListState() + + // 新消息 / 流式更新时滚到底。 + androidx.compose.runtime.LaunchedEffect(messages.size, streamingText) { + val total = messages.size + if (streaming) 1 else 0 + if (total > 0) listState.animateScrollToItem(total - 1) + } + + fun send() { + val text = input.trim() + if (text.isEmpty() || streaming) return + input = "" + val target = selectedTarget + scope.launch { + val now = System.currentTimeMillis() + var convId = currentConvId + if (convId == null) { + convId = withContext(Dispatchers.IO) { + repo.chats.insertConversation( + ChatConversation( + title = text.take(20), + targetId = target.id, + createdAt = now, + updatedAt = now, + ), + ) + } + currentConvId = convId + } + withContext(Dispatchers.IO) { + repo.chats.insertMessage(ChatMessage(conversationId = convId!!, role = "user", content = text, createdAt = now)) + repo.chats.touch(convId!!, now) + } + val history = withContext(Dispatchers.IO) { repo.chats.messagesOf(convId!!) } + .map { it.role to it.content } + + streaming = true + streamingText = "" + try { + val full = client.stream(target, history) { delta -> streamingText += delta } + withContext(Dispatchers.IO) { + repo.chats.insertMessage( + ChatMessage( + conversationId = convId!!, + role = "assistant", + content = full.ifBlank { "(空回复)" }, + model = target.model, + createdAt = System.currentTimeMillis(), + ), + ) + repo.chats.touch(convId!!, System.currentTimeMillis()) + } + } catch (t: Throwable) { + withContext(Dispatchers.IO) { + repo.chats.insertMessage( + ChatMessage( + conversationId = convId!!, + role = "assistant", + content = "⚠ 出错:${t.message ?: t.toString()}", + model = target.model, + createdAt = System.currentTimeMillis(), + ), + ) + } + } finally { + streaming = false + streamingText = "" + } + } + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet(modifier = Modifier.widthIn(max = 320.dp)) { + Column(modifier = Modifier.fillMaxHeight().padding(12.dp)) { + Text("对话历史", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp)) + FilledTonalButton( + onClick = { + currentConvId = null + streamingText = "" + scope.launch { drawerState.close() } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.height(0.dp)) + Text(" 新对话") + } + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + items(conversations, key = { it.id }) { conv -> + NavigationDrawerItem( + label = { + Text( + conv.title.ifBlank { "(未命名)" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = conv.id == currentConvId, + onClick = { + currentConvId = conv.id + selectedTargetId = conv.targetId + streamingText = "" + scope.launch { drawerState.close() } + }, + badge = { + IconButton(onClick = { + scope.launch { + withContext(Dispatchers.IO) { + repo.chats.deleteMessages(conv.id) + repo.chats.deleteConversation(conv.id) + } + if (currentConvId == conv.id) currentConvId = null + } + }) { Icon(Icons.Filled.Delete, contentDescription = "删除") } + }, + ) + } + } + } + } + }, + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 顶栏:菜单 + 聊天对象选择 + 设置。 + TopAppBar( + title = { TargetSelector(targets = targets, selected = selectedTarget) { picked -> + selectedTargetId = picked.id + val cid = currentConvId + if (cid != null) { + scope.launch { + withContext(Dispatchers.IO) { repo.chats.updateTarget(cid, picked.id, System.currentTimeMillis()) } + } + } + } }, + navigationIcon = { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Filled.Menu, contentDescription = "对话列表") + } + }, + actions = { + IconButton(onClick = { currentConvId = null; streamingText = "" }) { + Icon(Icons.Filled.Add, contentDescription = "新对话") + } + IconButton(onClick = { showSettings = true }) { + Icon(Icons.Filled.Settings, contentDescription = "端点设置") + } + }, + ) + + // 消息列表。 + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (messages.isEmpty() && !streaming) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("💬", style = MaterialTheme.typography.displayMedium) + Spacer(Modifier.height(8.dp)) + Text("和「${selectedTarget.label}」开始对话", style = MaterialTheme.typography.titleMedium) + Text( + "本地 2B/4B 走 ${ChatConfig.localBase(ctx)},Hermes 走 gateway。点左上角看历史。", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) } + if (streaming) { + item(key = "streaming") { + MessageBubble("assistant", streamingText.ifEmpty { "…" }, inProgress = true) + } + } + } + } + } + + HorizontalDivider() + + // 输入区。 + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("发消息给 ${selectedTarget.label}…") }, + maxLines = 5, + ) + IconButton(onClick = { send() }, enabled = input.isNotBlank() && !streaming) { + if (streaming) { + CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") + } + } + } + } + } + + if (showSettings) { + EndpointSettingsDialog( + initialBase = ChatConfig.localBase(ctx), + initialModel2b = ChatConfig.model2b(ctx), + initialModel4b = ChatConfig.model4b(ctx), + onDismiss = { showSettings = false }, + onSave = { base, m2, m4 -> + ChatConfig.save(ctx, base, m2, m4) + configRev++ + showSettings = false + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TargetSelector(targets: List, selected: ChatTarget, onSelect: (ChatTarget) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + Row( + modifier = Modifier.clickable { expanded = true }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selected.label, style = MaterialTheme.typography.titleMedium) + Text(" ▾", style = MaterialTheme.typography.titleMedium) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + targets.forEach { t -> + DropdownMenuItem( + text = { + Column { + Text(t.label) + Text(t.model, style = MaterialTheme.typography.labelSmall) + } + }, + onClick = { onSelect(t); expanded = false }, + ) + } + } + } +} + +@Composable +private fun MessageBubble(role: String, content: String, inProgress: Boolean = false) { + val isUser = role == "user" + val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Surface( + color = bubbleColor, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.widthIn(max = 300.dp), + ) { + Text( + content + if (inProgress) " ▍" else "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } +} + +@Composable +private fun EndpointSettingsDialog( + initialBase: String, + initialModel2b: String, + initialModel4b: String, + onDismiss: () -> Unit, + onSave: (String, String, String) -> Unit, +) { + var base by remember { mutableStateOf(initialBase) } + var m2 by remember { mutableStateOf(initialModel2b) } + var m4 by remember { mutableStateOf(initialModel4b) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("本地端点设置") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "2B/4B 共用同一个 llama-server 端口,按 model 名热切换。Hermes 端点由 keys.properties 配。", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = base, onValueChange = { base = it }, + label = { Text("本地 baseUrl") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = m2, onValueChange = { m2 = it }, + label = { Text("2B model 名") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = m4, onValueChange = { m4 = it }, + label = { Text("4B model 名") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { TextButton(onClick = { onSave(base, m2, m4) }) { Text("保存") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} 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 9a2b1db..33cb4e1 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 @@ -109,7 +109,8 @@ class MainActivity : ComponentActivity() { } private enum class Tab(val label: String, val icon: String) { - Home("首页", "●"), + Home("录音", "⏺"), + Chat("对话", "💬"), Timeline("时间轴", "≣"), Speakers("声纹", "👤"), Summaries("纪要", "✎"), @@ -157,7 +158,8 @@ private fun MainScreen() { ) { inner -> Box(modifier = Modifier.padding(inner)) { when (tab) { - Tab.Home -> HomeTab(activity) + Tab.Home -> HomeTab(activity, repo, onOpenTimeline = { tab = Tab.Timeline }) + Tab.Chat -> ChatTab(repo) Tab.Timeline -> TimelineTab( repo = repo, speakers = speakers, @@ -191,7 +193,7 @@ private fun MainScreen() { // ============================================================ Home tab @Composable -private fun HomeTab(activity: Context) { +private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit) { val running by RecorderState.isRunning.collectAsState() val paused by RecorderState.isPaused.collectAsState() val status by RecorderState.status.collectAsState() @@ -199,6 +201,7 @@ private fun HomeTab(activity: Context) { val lastAudioMs by RecorderState.lastAudioMs.collectAsState() val lastWallMs by RecorderState.lastWallMs.collectAsState() val scope = rememberCoroutineScope() + val recent by repo.segments.observeTimeline(5).collectAsState(initial = emptyList()) var asrStatus by remember { mutableStateOf("ASR: idle") } var asrLog by remember { mutableStateOf("") } @@ -266,6 +269,28 @@ private fun HomeTab(activity: Context) { ) } + HorizontalDivider() + + // 最近转写(实时):让用户在首页就能看到实时进度。 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("最近转写", style = MaterialTheme.typography.titleSmall) + TextButton(onClick = onOpenTimeline) { Text("查看全部 →") } + } + if (recent.isEmpty()) { + Text( + if (running) "正在聆听…说话后这里会实时出现转写" else "还没有转写。开始录音后这里实时刷新。", + style = MaterialTheme.typography.bodySmall, + ) + } else { + recent.forEach { row -> + TimelineRow(row = row, isPlaying = false, onClick = onOpenTimeline) + } + } + // Battery whitelist. Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt index ac247bd..2d13171 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt @@ -30,7 +30,8 @@ class HermesClient( private val http: OkHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) // Kimi 大段总结可以慢 + // 本地 llama-server 处理长会话(数千 token prompt + 热降频)可能要几分钟。 + .readTimeout(600, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatClient.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatClient.kt new file mode 100644 index 0000000..8d99b72 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatClient.kt @@ -0,0 +1,118 @@ +package com.lemon.lifelog.chat + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * OpenAI 兼容的聊天客户端,支持 SSE 流式(stream:true)增量回复。 + * + * 对本地 llama-server 走流式逐 token 出字;若端点不支持流式(返回 application/json + * 而非 text/event-stream,例如 hermes gateway 直接返回整段),自动降级为整段解析。 + */ +class ChatClient { + companion object { + private const val TAG = "ChatClient" + private val JSON = "application/json; charset=utf-8".toMediaType() + } + + private val http: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + // 本地 2B/4B 长 prompt + 热降频可能要几分钟。 + .readTimeout(600, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + /** + * 发起一次对话。[messages] 是 (role, content) 历史(含本次 user 消息)。 + * 每收到一段增量调用 [onDelta],返回完整回复文本。 + */ + @Throws(IOException::class) + suspend fun stream( + target: ChatTarget, + messages: List>, + maxTokens: Int = 1024, + onDelta: (String) -> Unit, + ): String = withContext(Dispatchers.IO) { + val payload = JSONObject().apply { + put("model", target.model) + put("stream", true) + put("max_tokens", maxTokens) + put("messages", JSONArray().apply { + messages.forEach { (role, content) -> + put(JSONObject().apply { + put("role", role) + put("content", content) + }) + } + }) + } + val builder = Request.Builder() + .url("${target.baseUrl}/v1/chat/completions") + .header("Content-Type", "application/json") + .post(payload.toString().toRequestBody(JSON)) + if (target.apiKey.isNotBlank()) { + builder.header("Authorization", "Bearer ${target.apiKey}") + } + + http.newCall(builder.build()).execute().use { resp -> + if (!resp.isSuccessful) { + val body = resp.body?.string().orEmpty() + throw IOException("${target.label} ${resp.code}: ${body.take(200)}") + } + val contentType = resp.header("Content-Type").orEmpty() + val body = resp.body ?: throw IOException("empty body") + + if (contentType.contains("event-stream")) { + val sb = StringBuilder() + val source = body.source() + while (!source.exhausted()) { + val line = source.readUtf8Line() ?: break + if (!line.startsWith("data:")) continue + val data = line.substring(5).trim() + if (data.isEmpty()) continue + if (data == "[DONE]") break + val delta = parseDelta(data) ?: continue + if (delta.isNotEmpty()) { + sb.append(delta) + onDelta(delta) + } + } + sb.toString() + } else { + // 非流式端点:一次性返回整段。 + val text = parseFullContent(body.string()) + if (text.isNotEmpty()) onDelta(text) + text + } + } + } + + /** 解析 SSE chunk 的 choices[0].delta.content。 */ + private fun parseDelta(data: String): String? { + return try { + val choices = JSONObject(data).optJSONArray("choices") ?: return null + if (choices.length() == 0) return null + choices.getJSONObject(0).optJSONObject("delta")?.optString("content", "") + } catch (t: Throwable) { + Log.w(TAG, "bad chunk: ${data.take(80)}") + null + } + } + + /** 解析非流式响应的 choices[0].message.content。 */ + private fun parseFullContent(body: String): String { + val json = JSONObject(body) + val choices = json.optJSONArray("choices") ?: return "" + if (choices.length() == 0) return "" + return choices.getJSONObject(0).optJSONObject("message")?.optString("content", "").orEmpty() + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatConfig.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatConfig.kt new file mode 100644 index 0000000..ca90687 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatConfig.kt @@ -0,0 +1,78 @@ +package com.lemon.lifelog.chat + +import android.content.Context +import com.lemon.lifelog.BuildConfig + +enum class ChatTargetKind { LOCAL, HERMES } + +/** + * 一个可聊天的对象 = 一个 OpenAI 兼容端点 + 一个 model 名。 + * + * 本地 2B/4B 共用同一个 llama-server 端口(llama-swap 按 model 字段热切换加载); + * Hermes Agent 走 gateway 8642。切换聊天对象 = 切 (baseUrl, model, apiKey)。 + */ +data class ChatTarget( + val id: String, + val label: String, + val baseUrl: String, + val apiKey: String, + val model: String, + val kind: ChatTargetKind, +) + +/** SharedPreferences 支持的聊天端点配置(本地端口/模型名可在 app 内改)。 */ +object ChatConfig { + const val PREFS = "chat_config" + private const val K_LOCAL_BASE = "local_base" + private const val K_MODEL_2B = "model_2b" + private const val K_MODEL_4B = "model_4b" + + const val DEFAULT_LOCAL_BASE = "http://127.0.0.1:8080" + const val DEFAULT_MODEL_2B = "2b" + const val DEFAULT_MODEL_4B = "4b" + + const val ID_2B = "local-2b" + const val ID_4B = "local-4b" + const val ID_HERMES = "hermes" + + private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun localBase(ctx: Context): String = + prefs(ctx).getString(K_LOCAL_BASE, DEFAULT_LOCAL_BASE)?.trimEnd('/').orEmpty().ifEmpty { DEFAULT_LOCAL_BASE } + + fun model2b(ctx: Context): String = + prefs(ctx).getString(K_MODEL_2B, DEFAULT_MODEL_2B)?.ifBlank { DEFAULT_MODEL_2B } ?: DEFAULT_MODEL_2B + + fun model4b(ctx: Context): String = + prefs(ctx).getString(K_MODEL_4B, DEFAULT_MODEL_4B)?.ifBlank { DEFAULT_MODEL_4B } ?: DEFAULT_MODEL_4B + + fun save(ctx: Context, localBase: String, model2b: String, model4b: String) { + prefs(ctx).edit() + .putString(K_LOCAL_BASE, localBase.trim()) + .putString(K_MODEL_2B, model2b.trim()) + .putString(K_MODEL_4B, model4b.trim()) + .apply() + } + + /** 当前可选的聊天对象列表。 */ + fun targets(ctx: Context): List { + val local = localBase(ctx) + return listOf( + ChatTarget(ID_2B, "本地 2B", local, "", model2b(ctx), ChatTargetKind.LOCAL), + ChatTarget(ID_4B, "本地 4B", local, "", model4b(ctx), ChatTargetKind.LOCAL), + ChatTarget( + ID_HERMES, + "Hermes Agent", + BuildConfig.HERMES_API_URL.trimEnd('/'), + BuildConfig.HERMES_API_KEY, + BuildConfig.HERMES_MODEL, + ChatTargetKind.HERMES, + ), + ) + } + + fun targetById(ctx: Context, id: String?): ChatTarget { + val all = targets(ctx) + return all.firstOrNull { it.id == id } ?: all.first() + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index b9f9d62..6576cc8 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [ @@ -12,8 +14,10 @@ import androidx.room.RoomDatabase Summary::class, SpeakerProfile::class, TranscriptFts::class, + ChatConversation::class, + ChatMessage::class, ], - version = 2, + version = 3, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -21,11 +25,24 @@ abstract class AppDatabase : RoomDatabase() { abstract fun transcriptDao(): TranscriptDao abstract fun summaryDao(): SummaryDao abstract fun speakerDao(): SpeakerDao + abstract fun chatDao(): ChatDao companion object { @Volatile private var instance: AppDatabase? = null + /** v0.6: 新增 chat 两张表。纯增量——保留已有录音/转写/声纹/纪要数据。 */ + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `chat_conversations` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `targetId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `chat_messages` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` INTEGER NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `model` TEXT, `createdAt` INTEGER NOT NULL)", + ) + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -33,7 +50,8 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .fallbackToDestructiveMigration() // v0.3 dev: schema 大改,旧测试数据可丢 + .addMigrations(MIGRATION_2_3) + .fallbackToDestructiveMigration() // 1→x 等无迁移路径仍兜底(dev 早期库可丢) .build() .also { instance = it } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index a021920..84013ca 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -152,6 +152,42 @@ interface SummaryDao { } @Dao +interface ChatDao { + @Insert + suspend fun insertConversation(c: ChatConversation): Long + + @Insert + suspend fun insertMessage(m: ChatMessage): Long + + @Query("SELECT * FROM chat_conversations ORDER BY updatedAt DESC") + fun observeConversations(): Flow> + + @Query("SELECT * FROM chat_conversations WHERE id = :id") + suspend fun getConversation(id: Long): ChatConversation? + + @Query("SELECT * FROM chat_messages WHERE conversationId = :cid ORDER BY createdAt ASC, id ASC") + fun observeMessages(cid: Long): Flow> + + @Query("SELECT * FROM chat_messages WHERE conversationId = :cid ORDER BY createdAt ASC, id ASC") + suspend fun messagesOf(cid: Long): List + + @Query("UPDATE chat_conversations SET title = :title, updatedAt = :ts WHERE id = :id") + suspend fun updateTitle(id: Long, title: String, ts: Long) + + @Query("UPDATE chat_conversations SET targetId = :targetId, updatedAt = :ts WHERE id = :id") + suspend fun updateTarget(id: Long, targetId: String, ts: Long) + + @Query("UPDATE chat_conversations SET updatedAt = :ts WHERE id = :id") + suspend fun touch(id: Long, ts: Long) + + @Query("DELETE FROM chat_messages WHERE conversationId = :cid") + suspend fun deleteMessages(cid: Long) + + @Query("DELETE FROM chat_conversations WHERE id = :id") + suspend fun deleteConversation(id: Long) +} + +@Dao interface SpeakerDao { @Insert suspend fun insert(p: SpeakerProfile): Long diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index d019871..a0157f8 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -57,3 +57,26 @@ data class SpeakerProfile( data class TranscriptFts( val text: String, ) + +/** 一个对话会话(ChatGPT 左侧列表的一行)。targetId 记录最近一次用的聊天对象。 */ +@Entity(tableName = "chat_conversations") +data class ChatConversation( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + /** ChatTarget.id:local-2b / local-4b / hermes */ + val targetId: String, + val createdAt: Long, + val updatedAt: Long, +) + +/** 会话里的一条消息。role = "user" | "assistant"。 */ +@Entity(tableName = "chat_messages") +data class ChatMessage( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val conversationId: Long, + val role: String, + val content: String, + /** assistant 消息记录实际回复模型名,user 消息为 null */ + val model: String? = null, + val createdAt: Long, +) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt index 81a9c46..962d0da 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt @@ -8,4 +8,5 @@ class Repository(ctx: Context) { val transcripts = db.transcriptDao() val summaries = db.summaryDao() val speakers = db.speakerDao() + val chats = db.chatDao() } -- libgit2 0.21.4