Commit fd8e772a907e67303bc10d8e1ada10727b4cb343

Authored by yibo
1 parent 59528205

feat(lifelog/chat): 新增「对话」tab — 本地 2B/4B + Hermes 多端点聊天

- 首页 tab 改名「录音」,新增「对话」tab(ChatGPT 式:抽屉历史 / 气泡 / 流式 / 输入框)
- chat/ChatConfig:聊天对象 = OpenAI 兼容端点 + model;本地 2B/4B 共用一个 llama-server
  端口按 model 热切换,Hermes 走 gateway 8642;本地 baseUrl/model 名 app 内 ⚙ 可改
- chat/ChatClient:SSE 流式逐 token,端点不支持流式时自动降级整段解析
- 持久化到 Room:chat_conversations / chat_messages 两表,切换对象记到会话 targetId
- data:AppDatabase 加 MIGRATION_2_3(纯增量加表),修复 fallbackToDestructiveMigration
  在 24/7 RecorderService 重装时静默清库的风险

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/.gitignore
... ... @@ -15,3 +15,6 @@ keys.properties
15 15  
16 16 # Build outputs
17 17 **/build/
  18 +
  19 +# 录音恢复备份(DB 误清后从设备抢救的原始音频,勿入库)
  20 +_recovered_segments_*/
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt 0 → 100644
  1 +package com.lemon.lifelog
  2 +
  3 +import androidx.compose.foundation.clickable
  4 +import androidx.compose.foundation.layout.Arrangement
  5 +import androidx.compose.foundation.layout.Box
  6 +import androidx.compose.foundation.layout.Column
  7 +import androidx.compose.foundation.layout.Row
  8 +import androidx.compose.foundation.layout.Spacer
  9 +import androidx.compose.foundation.layout.fillMaxSize
  10 +import androidx.compose.foundation.layout.fillMaxWidth
  11 +import androidx.compose.foundation.layout.fillMaxHeight
  12 +import androidx.compose.foundation.layout.height
  13 +import androidx.compose.foundation.layout.padding
  14 +import androidx.compose.foundation.layout.widthIn
  15 +import androidx.compose.foundation.lazy.LazyColumn
  16 +import androidx.compose.foundation.lazy.items
  17 +import androidx.compose.foundation.lazy.rememberLazyListState
  18 +import androidx.compose.foundation.shape.RoundedCornerShape
  19 +import androidx.compose.material3.AlertDialog
  20 +import androidx.compose.material3.CircularProgressIndicator
  21 +import androidx.compose.material3.DropdownMenu
  22 +import androidx.compose.material3.DropdownMenuItem
  23 +import androidx.compose.material3.ExperimentalMaterial3Api
  24 +import androidx.compose.material3.FilledTonalButton
  25 +import androidx.compose.material3.HorizontalDivider
  26 +import androidx.compose.material3.Icon
  27 +import androidx.compose.material3.IconButton
  28 +import androidx.compose.material3.MaterialTheme
  29 +import androidx.compose.material3.ModalDrawerSheet
  30 +import androidx.compose.material3.ModalNavigationDrawer
  31 +import androidx.compose.material3.NavigationDrawerItem
  32 +import androidx.compose.material3.OutlinedTextField
  33 +import androidx.compose.material3.Surface
  34 +import androidx.compose.material3.Text
  35 +import androidx.compose.material3.TextButton
  36 +import androidx.compose.material3.TopAppBar
  37 +import androidx.compose.material3.rememberDrawerState
  38 +import androidx.compose.material3.DrawerValue
  39 +import androidx.compose.material.icons.Icons
  40 +import androidx.compose.material.icons.automirrored.filled.Send
  41 +import androidx.compose.material.icons.filled.Add
  42 +import androidx.compose.material.icons.filled.Delete
  43 +import androidx.compose.material.icons.filled.Menu
  44 +import androidx.compose.material.icons.filled.Settings
  45 +import androidx.compose.runtime.Composable
  46 +import androidx.compose.runtime.collectAsState
  47 +import androidx.compose.runtime.getValue
  48 +import androidx.compose.runtime.mutableStateOf
  49 +import androidx.compose.runtime.remember
  50 +import androidx.compose.runtime.rememberCoroutineScope
  51 +import androidx.compose.runtime.setValue
  52 +import androidx.compose.ui.Alignment
  53 +import androidx.compose.ui.Modifier
  54 +import androidx.compose.ui.platform.LocalContext
  55 +import androidx.compose.ui.text.style.TextOverflow
  56 +import androidx.compose.ui.unit.dp
  57 +import com.lemon.lifelog.chat.ChatClient
  58 +import com.lemon.lifelog.chat.ChatConfig
  59 +import com.lemon.lifelog.chat.ChatTarget
  60 +import com.lemon.lifelog.data.ChatConversation
  61 +import com.lemon.lifelog.data.ChatMessage
  62 +import com.lemon.lifelog.data.Repository
  63 +import kotlinx.coroutines.Dispatchers
  64 +import kotlinx.coroutines.launch
  65 +import kotlinx.coroutines.withContext
  66 +
  67 +@OptIn(ExperimentalMaterial3Api::class)
  68 +@Composable
  69 +internal fun ChatTab(repo: Repository) {
  70 + val ctx = LocalContext.current
  71 + val scope = rememberCoroutineScope()
  72 + val client = remember { ChatClient() }
  73 +
  74 + // 配置刷新计数器:改完端点设置后 bump 一下重新读 targets。
  75 + var configRev by remember { mutableStateOf(0) }
  76 + val targets = remember(configRev) { ChatConfig.targets(ctx) }
  77 +
  78 + val conversations by repo.chats.observeConversations().collectAsState(initial = emptyList())
  79 +
  80 + var currentConvId by remember { mutableStateOf<Long?>(null) }
  81 + var selectedTargetId by remember { mutableStateOf(targets.first().id) }
  82 + val selectedTarget = remember(selectedTargetId, configRev) { ChatConfig.targetById(ctx, selectedTargetId) }
  83 +
  84 + // 当前会话的消息流。
  85 + val messages by remember(currentConvId) {
  86 + val cid = currentConvId
  87 + if (cid == null) kotlinx.coroutines.flow.flowOf(emptyList())
  88 + else repo.chats.observeMessages(cid)
  89 + }.collectAsState(initial = emptyList())
  90 +
  91 + // 流式中的 assistant 回复(还没落库)。
  92 + var streaming by remember { mutableStateOf(false) }
  93 + var streamingText by remember { mutableStateOf("") }
  94 +
  95 + var showSettings by remember { mutableStateOf(false) }
  96 + var input by remember { mutableStateOf("") }
  97 +
  98 + val drawerState = rememberDrawerState(DrawerValue.Closed)
  99 + val listState = rememberLazyListState()
  100 +
  101 + // 新消息 / 流式更新时滚到底。
  102 + androidx.compose.runtime.LaunchedEffect(messages.size, streamingText) {
  103 + val total = messages.size + if (streaming) 1 else 0
  104 + if (total > 0) listState.animateScrollToItem(total - 1)
  105 + }
  106 +
  107 + fun send() {
  108 + val text = input.trim()
  109 + if (text.isEmpty() || streaming) return
  110 + input = ""
  111 + val target = selectedTarget
  112 + scope.launch {
  113 + val now = System.currentTimeMillis()
  114 + var convId = currentConvId
  115 + if (convId == null) {
  116 + convId = withContext(Dispatchers.IO) {
  117 + repo.chats.insertConversation(
  118 + ChatConversation(
  119 + title = text.take(20),
  120 + targetId = target.id,
  121 + createdAt = now,
  122 + updatedAt = now,
  123 + ),
  124 + )
  125 + }
  126 + currentConvId = convId
  127 + }
  128 + withContext(Dispatchers.IO) {
  129 + repo.chats.insertMessage(ChatMessage(conversationId = convId!!, role = "user", content = text, createdAt = now))
  130 + repo.chats.touch(convId!!, now)
  131 + }
  132 + val history = withContext(Dispatchers.IO) { repo.chats.messagesOf(convId!!) }
  133 + .map { it.role to it.content }
  134 +
  135 + streaming = true
  136 + streamingText = ""
  137 + try {
  138 + val full = client.stream(target, history) { delta -> streamingText += delta }
  139 + withContext(Dispatchers.IO) {
  140 + repo.chats.insertMessage(
  141 + ChatMessage(
  142 + conversationId = convId!!,
  143 + role = "assistant",
  144 + content = full.ifBlank { "(空回复)" },
  145 + model = target.model,
  146 + createdAt = System.currentTimeMillis(),
  147 + ),
  148 + )
  149 + repo.chats.touch(convId!!, System.currentTimeMillis())
  150 + }
  151 + } catch (t: Throwable) {
  152 + withContext(Dispatchers.IO) {
  153 + repo.chats.insertMessage(
  154 + ChatMessage(
  155 + conversationId = convId!!,
  156 + role = "assistant",
  157 + content = "⚠ 出错:${t.message ?: t.toString()}",
  158 + model = target.model,
  159 + createdAt = System.currentTimeMillis(),
  160 + ),
  161 + )
  162 + }
  163 + } finally {
  164 + streaming = false
  165 + streamingText = ""
  166 + }
  167 + }
  168 + }
  169 +
  170 + ModalNavigationDrawer(
  171 + drawerState = drawerState,
  172 + drawerContent = {
  173 + ModalDrawerSheet(modifier = Modifier.widthIn(max = 320.dp)) {
  174 + Column(modifier = Modifier.fillMaxHeight().padding(12.dp)) {
  175 + Text("对话历史", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
  176 + FilledTonalButton(
  177 + onClick = {
  178 + currentConvId = null
  179 + streamingText = ""
  180 + scope.launch { drawerState.close() }
  181 + },
  182 + modifier = Modifier.fillMaxWidth(),
  183 + ) {
  184 + Icon(Icons.Filled.Add, contentDescription = null)
  185 + Spacer(Modifier.height(0.dp))
  186 + Text(" 新对话")
  187 + }
  188 + Spacer(Modifier.height(8.dp))
  189 + HorizontalDivider()
  190 + LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
  191 + items(conversations, key = { it.id }) { conv ->
  192 + NavigationDrawerItem(
  193 + label = {
  194 + Text(
  195 + conv.title.ifBlank { "(未命名)" },
  196 + maxLines = 1,
  197 + overflow = TextOverflow.Ellipsis,
  198 + )
  199 + },
  200 + selected = conv.id == currentConvId,
  201 + onClick = {
  202 + currentConvId = conv.id
  203 + selectedTargetId = conv.targetId
  204 + streamingText = ""
  205 + scope.launch { drawerState.close() }
  206 + },
  207 + badge = {
  208 + IconButton(onClick = {
  209 + scope.launch {
  210 + withContext(Dispatchers.IO) {
  211 + repo.chats.deleteMessages(conv.id)
  212 + repo.chats.deleteConversation(conv.id)
  213 + }
  214 + if (currentConvId == conv.id) currentConvId = null
  215 + }
  216 + }) { Icon(Icons.Filled.Delete, contentDescription = "删除") }
  217 + },
  218 + )
  219 + }
  220 + }
  221 + }
  222 + }
  223 + },
  224 + ) {
  225 + Column(modifier = Modifier.fillMaxSize()) {
  226 + // 顶栏:菜单 + 聊天对象选择 + 设置。
  227 + TopAppBar(
  228 + title = { TargetSelector(targets = targets, selected = selectedTarget) { picked ->
  229 + selectedTargetId = picked.id
  230 + val cid = currentConvId
  231 + if (cid != null) {
  232 + scope.launch {
  233 + withContext(Dispatchers.IO) { repo.chats.updateTarget(cid, picked.id, System.currentTimeMillis()) }
  234 + }
  235 + }
  236 + } },
  237 + navigationIcon = {
  238 + IconButton(onClick = { scope.launch { drawerState.open() } }) {
  239 + Icon(Icons.Filled.Menu, contentDescription = "对话列表")
  240 + }
  241 + },
  242 + actions = {
  243 + IconButton(onClick = { currentConvId = null; streamingText = "" }) {
  244 + Icon(Icons.Filled.Add, contentDescription = "新对话")
  245 + }
  246 + IconButton(onClick = { showSettings = true }) {
  247 + Icon(Icons.Filled.Settings, contentDescription = "端点设置")
  248 + }
  249 + },
  250 + )
  251 +
  252 + // 消息列表。
  253 + Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
  254 + if (messages.isEmpty() && !streaming) {
  255 + Column(
  256 + modifier = Modifier.fillMaxSize().padding(24.dp),
  257 + verticalArrangement = Arrangement.Center,
  258 + horizontalAlignment = Alignment.CenterHorizontally,
  259 + ) {
  260 + Text("💬", style = MaterialTheme.typography.displayMedium)
  261 + Spacer(Modifier.height(8.dp))
  262 + Text("和「${selectedTarget.label}」开始对话", style = MaterialTheme.typography.titleMedium)
  263 + Text(
  264 + "本地 2B/4B 走 ${ChatConfig.localBase(ctx)},Hermes 走 gateway。点左上角看历史。",
  265 + style = MaterialTheme.typography.bodySmall,
  266 + modifier = Modifier.padding(top = 8.dp),
  267 + )
  268 + }
  269 + } else {
  270 + LazyColumn(
  271 + state = listState,
  272 + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
  273 + verticalArrangement = Arrangement.spacedBy(8.dp),
  274 + ) {
  275 + items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) }
  276 + if (streaming) {
  277 + item(key = "streaming") {
  278 + MessageBubble("assistant", streamingText.ifEmpty { "…" }, inProgress = true)
  279 + }
  280 + }
  281 + }
  282 + }
  283 + }
  284 +
  285 + HorizontalDivider()
  286 +
  287 + // 输入区。
  288 + Row(
  289 + modifier = Modifier.fillMaxWidth().padding(8.dp),
  290 + verticalAlignment = Alignment.CenterVertically,
  291 + horizontalArrangement = Arrangement.spacedBy(8.dp),
  292 + ) {
  293 + OutlinedTextField(
  294 + value = input,
  295 + onValueChange = { input = it },
  296 + modifier = Modifier.weight(1f),
  297 + placeholder = { Text("发消息给 ${selectedTarget.label}…") },
  298 + maxLines = 5,
  299 + )
  300 + IconButton(onClick = { send() }, enabled = input.isNotBlank() && !streaming) {
  301 + if (streaming) {
  302 + CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp)
  303 + } else {
  304 + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
  305 + }
  306 + }
  307 + }
  308 + }
  309 + }
  310 +
  311 + if (showSettings) {
  312 + EndpointSettingsDialog(
  313 + initialBase = ChatConfig.localBase(ctx),
  314 + initialModel2b = ChatConfig.model2b(ctx),
  315 + initialModel4b = ChatConfig.model4b(ctx),
  316 + onDismiss = { showSettings = false },
  317 + onSave = { base, m2, m4 ->
  318 + ChatConfig.save(ctx, base, m2, m4)
  319 + configRev++
  320 + showSettings = false
  321 + },
  322 + )
  323 + }
  324 +}
  325 +
  326 +@OptIn(ExperimentalMaterial3Api::class)
  327 +@Composable
  328 +private fun TargetSelector(targets: List<ChatTarget>, selected: ChatTarget, onSelect: (ChatTarget) -> Unit) {
  329 + var expanded by remember { mutableStateOf(false) }
  330 + Box {
  331 + Row(
  332 + modifier = Modifier.clickable { expanded = true }.padding(vertical = 4.dp),
  333 + verticalAlignment = Alignment.CenterVertically,
  334 + ) {
  335 + Text(selected.label, style = MaterialTheme.typography.titleMedium)
  336 + Text(" ▾", style = MaterialTheme.typography.titleMedium)
  337 + }
  338 + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
  339 + targets.forEach { t ->
  340 + DropdownMenuItem(
  341 + text = {
  342 + Column {
  343 + Text(t.label)
  344 + Text(t.model, style = MaterialTheme.typography.labelSmall)
  345 + }
  346 + },
  347 + onClick = { onSelect(t); expanded = false },
  348 + )
  349 + }
  350 + }
  351 + }
  352 +}
  353 +
  354 +@Composable
  355 +private fun MessageBubble(role: String, content: String, inProgress: Boolean = false) {
  356 + val isUser = role == "user"
  357 + val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
  358 + Row(
  359 + modifier = Modifier.fillMaxWidth(),
  360 + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
  361 + ) {
  362 + Surface(
  363 + color = bubbleColor,
  364 + shape = RoundedCornerShape(14.dp),
  365 + modifier = Modifier.widthIn(max = 300.dp),
  366 + ) {
  367 + Text(
  368 + content + if (inProgress) " ▍" else "",
  369 + style = MaterialTheme.typography.bodyMedium,
  370 + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
  371 + )
  372 + }
  373 + }
  374 +}
  375 +
  376 +@Composable
  377 +private fun EndpointSettingsDialog(
  378 + initialBase: String,
  379 + initialModel2b: String,
  380 + initialModel4b: String,
  381 + onDismiss: () -> Unit,
  382 + onSave: (String, String, String) -> Unit,
  383 +) {
  384 + var base by remember { mutableStateOf(initialBase) }
  385 + var m2 by remember { mutableStateOf(initialModel2b) }
  386 + var m4 by remember { mutableStateOf(initialModel4b) }
  387 + AlertDialog(
  388 + onDismissRequest = onDismiss,
  389 + title = { Text("本地端点设置") },
  390 + text = {
  391 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
  392 + Text(
  393 + "2B/4B 共用同一个 llama-server 端口,按 model 名热切换。Hermes 端点由 keys.properties 配。",
  394 + style = MaterialTheme.typography.bodySmall,
  395 + )
  396 + OutlinedTextField(
  397 + value = base, onValueChange = { base = it },
  398 + label = { Text("本地 baseUrl") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
  399 + )
  400 + OutlinedTextField(
  401 + value = m2, onValueChange = { m2 = it },
  402 + label = { Text("2B model 名") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
  403 + )
  404 + OutlinedTextField(
  405 + value = m4, onValueChange = { m4 = it },
  406 + label = { Text("4B model 名") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
  407 + )
  408 + }
  409 + },
  410 + confirmButton = { TextButton(onClick = { onSave(base, m2, m4) }) { Text("保存") } },
  411 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  412 + )
  413 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -109,7 +109,8 @@ class MainActivity : ComponentActivity() {
109 109 }
110 110  
111 111 private enum class Tab(val label: String, val icon: String) {
112   - Home("首页", "●"),
  112 + Home("录音", "⏺"),
  113 + Chat("对话", "💬"),
113 114 Timeline("时间轴", "≣"),
114 115 Speakers("声纹", "👤"),
115 116 Summaries("纪要", "✎"),
... ... @@ -157,7 +158,8 @@ private fun MainScreen() {
157 158 ) { inner ->
158 159 Box(modifier = Modifier.padding(inner)) {
159 160 when (tab) {
160   - Tab.Home -> HomeTab(activity)
  161 + Tab.Home -> HomeTab(activity, repo, onOpenTimeline = { tab = Tab.Timeline })
  162 + Tab.Chat -> ChatTab(repo)
161 163 Tab.Timeline -> TimelineTab(
162 164 repo = repo,
163 165 speakers = speakers,
... ... @@ -191,7 +193,7 @@ private fun MainScreen() {
191 193 // ============================================================ Home tab
192 194  
193 195 @Composable
194   -private fun HomeTab(activity: Context) {
  196 +private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit) {
195 197 val running by RecorderState.isRunning.collectAsState()
196 198 val paused by RecorderState.isPaused.collectAsState()
197 199 val status by RecorderState.status.collectAsState()
... ... @@ -199,6 +201,7 @@ private fun HomeTab(activity: Context) {
199 201 val lastAudioMs by RecorderState.lastAudioMs.collectAsState()
200 202 val lastWallMs by RecorderState.lastWallMs.collectAsState()
201 203 val scope = rememberCoroutineScope()
  204 + val recent by repo.segments.observeTimeline(5).collectAsState(initial = emptyList())
202 205  
203 206 var asrStatus by remember { mutableStateOf("ASR: idle") }
204 207 var asrLog by remember { mutableStateOf("") }
... ... @@ -266,6 +269,28 @@ private fun HomeTab(activity: Context) {
266 269 )
267 270 }
268 271  
  272 + HorizontalDivider()
  273 +
  274 + // 最近转写(实时):让用户在首页就能看到实时进度。
  275 + Row(
  276 + modifier = Modifier.fillMaxWidth(),
  277 + horizontalArrangement = Arrangement.SpaceBetween,
  278 + verticalAlignment = Alignment.CenterVertically,
  279 + ) {
  280 + Text("最近转写", style = MaterialTheme.typography.titleSmall)
  281 + TextButton(onClick = onOpenTimeline) { Text("查看全部 →") }
  282 + }
  283 + if (recent.isEmpty()) {
  284 + Text(
  285 + if (running) "正在聆听…说话后这里会实时出现转写" else "还没有转写。开始录音后这里实时刷新。",
  286 + style = MaterialTheme.typography.bodySmall,
  287 + )
  288 + } else {
  289 + recent.forEach { row ->
  290 + TimelineRow(row = row, isPlaying = false, onClick = onOpenTimeline)
  291 + }
  292 + }
  293 +
269 294 // Battery whitelist.
270 295 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
271 296 Text(
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt
... ... @@ -30,7 +30,8 @@ class HermesClient(
30 30  
31 31 private val http: OkHttpClient = OkHttpClient.Builder()
32 32 .connectTimeout(10, TimeUnit.SECONDS)
33   - .readTimeout(120, TimeUnit.SECONDS) // Kimi 大段总结可以慢
  33 + // 本地 llama-server 处理长会话(数千 token prompt + 热降频)可能要几分钟。
  34 + .readTimeout(600, TimeUnit.SECONDS)
34 35 .writeTimeout(30, TimeUnit.SECONDS)
35 36 .build()
36 37  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatClient.kt 0 → 100644
  1 +package com.lemon.lifelog.chat
  2 +
  3 +import android.util.Log
  4 +import kotlinx.coroutines.Dispatchers
  5 +import kotlinx.coroutines.withContext
  6 +import okhttp3.MediaType.Companion.toMediaType
  7 +import okhttp3.OkHttpClient
  8 +import okhttp3.Request
  9 +import okhttp3.RequestBody.Companion.toRequestBody
  10 +import org.json.JSONArray
  11 +import org.json.JSONObject
  12 +import java.io.IOException
  13 +import java.util.concurrent.TimeUnit
  14 +
  15 +/**
  16 + * OpenAI 兼容的聊天客户端,支持 SSE 流式(stream:true)增量回复。
  17 + *
  18 + * 对本地 llama-server 走流式逐 token 出字;若端点不支持流式(返回 application/json
  19 + * 而非 text/event-stream,例如 hermes gateway 直接返回整段),自动降级为整段解析。
  20 + */
  21 +class ChatClient {
  22 + companion object {
  23 + private const val TAG = "ChatClient"
  24 + private val JSON = "application/json; charset=utf-8".toMediaType()
  25 + }
  26 +
  27 + private val http: OkHttpClient = OkHttpClient.Builder()
  28 + .connectTimeout(10, TimeUnit.SECONDS)
  29 + // 本地 2B/4B 长 prompt + 热降频可能要几分钟。
  30 + .readTimeout(600, TimeUnit.SECONDS)
  31 + .writeTimeout(30, TimeUnit.SECONDS)
  32 + .build()
  33 +
  34 + /**
  35 + * 发起一次对话。[messages] 是 (role, content) 历史(含本次 user 消息)。
  36 + * 每收到一段增量调用 [onDelta],返回完整回复文本。
  37 + */
  38 + @Throws(IOException::class)
  39 + suspend fun stream(
  40 + target: ChatTarget,
  41 + messages: List<Pair<String, String>>,
  42 + maxTokens: Int = 1024,
  43 + onDelta: (String) -> Unit,
  44 + ): String = withContext(Dispatchers.IO) {
  45 + val payload = JSONObject().apply {
  46 + put("model", target.model)
  47 + put("stream", true)
  48 + put("max_tokens", maxTokens)
  49 + put("messages", JSONArray().apply {
  50 + messages.forEach { (role, content) ->
  51 + put(JSONObject().apply {
  52 + put("role", role)
  53 + put("content", content)
  54 + })
  55 + }
  56 + })
  57 + }
  58 + val builder = Request.Builder()
  59 + .url("${target.baseUrl}/v1/chat/completions")
  60 + .header("Content-Type", "application/json")
  61 + .post(payload.toString().toRequestBody(JSON))
  62 + if (target.apiKey.isNotBlank()) {
  63 + builder.header("Authorization", "Bearer ${target.apiKey}")
  64 + }
  65 +
  66 + http.newCall(builder.build()).execute().use { resp ->
  67 + if (!resp.isSuccessful) {
  68 + val body = resp.body?.string().orEmpty()
  69 + throw IOException("${target.label} ${resp.code}: ${body.take(200)}")
  70 + }
  71 + val contentType = resp.header("Content-Type").orEmpty()
  72 + val body = resp.body ?: throw IOException("empty body")
  73 +
  74 + if (contentType.contains("event-stream")) {
  75 + val sb = StringBuilder()
  76 + val source = body.source()
  77 + while (!source.exhausted()) {
  78 + val line = source.readUtf8Line() ?: break
  79 + if (!line.startsWith("data:")) continue
  80 + val data = line.substring(5).trim()
  81 + if (data.isEmpty()) continue
  82 + if (data == "[DONE]") break
  83 + val delta = parseDelta(data) ?: continue
  84 + if (delta.isNotEmpty()) {
  85 + sb.append(delta)
  86 + onDelta(delta)
  87 + }
  88 + }
  89 + sb.toString()
  90 + } else {
  91 + // 非流式端点:一次性返回整段。
  92 + val text = parseFullContent(body.string())
  93 + if (text.isNotEmpty()) onDelta(text)
  94 + text
  95 + }
  96 + }
  97 + }
  98 +
  99 + /** 解析 SSE chunk 的 choices[0].delta.content。 */
  100 + private fun parseDelta(data: String): String? {
  101 + return try {
  102 + val choices = JSONObject(data).optJSONArray("choices") ?: return null
  103 + if (choices.length() == 0) return null
  104 + choices.getJSONObject(0).optJSONObject("delta")?.optString("content", "")
  105 + } catch (t: Throwable) {
  106 + Log.w(TAG, "bad chunk: ${data.take(80)}")
  107 + null
  108 + }
  109 + }
  110 +
  111 + /** 解析非流式响应的 choices[0].message.content。 */
  112 + private fun parseFullContent(body: String): String {
  113 + val json = JSONObject(body)
  114 + val choices = json.optJSONArray("choices") ?: return ""
  115 + if (choices.length() == 0) return ""
  116 + return choices.getJSONObject(0).optJSONObject("message")?.optString("content", "").orEmpty()
  117 + }
  118 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatConfig.kt 0 → 100644
  1 +package com.lemon.lifelog.chat
  2 +
  3 +import android.content.Context
  4 +import com.lemon.lifelog.BuildConfig
  5 +
  6 +enum class ChatTargetKind { LOCAL, HERMES }
  7 +
  8 +/**
  9 + * 一个可聊天的对象 = 一个 OpenAI 兼容端点 + 一个 model 名。
  10 + *
  11 + * 本地 2B/4B 共用同一个 llama-server 端口(llama-swap 按 model 字段热切换加载);
  12 + * Hermes Agent 走 gateway 8642。切换聊天对象 = 切 (baseUrl, model, apiKey)。
  13 + */
  14 +data class ChatTarget(
  15 + val id: String,
  16 + val label: String,
  17 + val baseUrl: String,
  18 + val apiKey: String,
  19 + val model: String,
  20 + val kind: ChatTargetKind,
  21 +)
  22 +
  23 +/** SharedPreferences 支持的聊天端点配置(本地端口/模型名可在 app 内改)。 */
  24 +object ChatConfig {
  25 + const val PREFS = "chat_config"
  26 + private const val K_LOCAL_BASE = "local_base"
  27 + private const val K_MODEL_2B = "model_2b"
  28 + private const val K_MODEL_4B = "model_4b"
  29 +
  30 + const val DEFAULT_LOCAL_BASE = "http://127.0.0.1:8080"
  31 + const val DEFAULT_MODEL_2B = "2b"
  32 + const val DEFAULT_MODEL_4B = "4b"
  33 +
  34 + const val ID_2B = "local-2b"
  35 + const val ID_4B = "local-4b"
  36 + const val ID_HERMES = "hermes"
  37 +
  38 + private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
  39 +
  40 + fun localBase(ctx: Context): String =
  41 + prefs(ctx).getString(K_LOCAL_BASE, DEFAULT_LOCAL_BASE)?.trimEnd('/').orEmpty().ifEmpty { DEFAULT_LOCAL_BASE }
  42 +
  43 + fun model2b(ctx: Context): String =
  44 + prefs(ctx).getString(K_MODEL_2B, DEFAULT_MODEL_2B)?.ifBlank { DEFAULT_MODEL_2B } ?: DEFAULT_MODEL_2B
  45 +
  46 + fun model4b(ctx: Context): String =
  47 + prefs(ctx).getString(K_MODEL_4B, DEFAULT_MODEL_4B)?.ifBlank { DEFAULT_MODEL_4B } ?: DEFAULT_MODEL_4B
  48 +
  49 + fun save(ctx: Context, localBase: String, model2b: String, model4b: String) {
  50 + prefs(ctx).edit()
  51 + .putString(K_LOCAL_BASE, localBase.trim())
  52 + .putString(K_MODEL_2B, model2b.trim())
  53 + .putString(K_MODEL_4B, model4b.trim())
  54 + .apply()
  55 + }
  56 +
  57 + /** 当前可选的聊天对象列表。 */
  58 + fun targets(ctx: Context): List<ChatTarget> {
  59 + val local = localBase(ctx)
  60 + return listOf(
  61 + ChatTarget(ID_2B, "本地 2B", local, "", model2b(ctx), ChatTargetKind.LOCAL),
  62 + ChatTarget(ID_4B, "本地 4B", local, "", model4b(ctx), ChatTargetKind.LOCAL),
  63 + ChatTarget(
  64 + ID_HERMES,
  65 + "Hermes Agent",
  66 + BuildConfig.HERMES_API_URL.trimEnd('/'),
  67 + BuildConfig.HERMES_API_KEY,
  68 + BuildConfig.HERMES_MODEL,
  69 + ChatTargetKind.HERMES,
  70 + ),
  71 + )
  72 + }
  73 +
  74 + fun targetById(ctx: Context, id: String?): ChatTarget {
  75 + val all = targets(ctx)
  76 + return all.firstOrNull { it.id == id } ?: all.first()
  77 + }
  78 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
... ... @@ -4,6 +4,8 @@ import android.content.Context
4 4 import androidx.room.Database
5 5 import androidx.room.Room
6 6 import androidx.room.RoomDatabase
  7 +import androidx.room.migration.Migration
  8 +import androidx.sqlite.db.SupportSQLiteDatabase
7 9  
8 10 @Database(
9 11 entities = [
... ... @@ -12,8 +14,10 @@ import androidx.room.RoomDatabase
12 14 Summary::class,
13 15 SpeakerProfile::class,
14 16 TranscriptFts::class,
  17 + ChatConversation::class,
  18 + ChatMessage::class,
15 19 ],
16   - version = 2,
  20 + version = 3,
17 21 exportSchema = false,
18 22 )
19 23 abstract class AppDatabase : RoomDatabase() {
... ... @@ -21,11 +25,24 @@ abstract class AppDatabase : RoomDatabase() {
21 25 abstract fun transcriptDao(): TranscriptDao
22 26 abstract fun summaryDao(): SummaryDao
23 27 abstract fun speakerDao(): SpeakerDao
  28 + abstract fun chatDao(): ChatDao
24 29  
25 30 companion object {
26 31 @Volatile
27 32 private var instance: AppDatabase? = null
28 33  
  34 + /** v0.6: 新增 chat 两张表。纯增量——保留已有录音/转写/声纹/纪要数据。 */
  35 + private val MIGRATION_2_3 = object : Migration(2, 3) {
  36 + override fun migrate(db: SupportSQLiteDatabase) {
  37 + db.execSQL(
  38 + "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)",
  39 + )
  40 + db.execSQL(
  41 + "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)",
  42 + )
  43 + }
  44 + }
  45 +
29 46 fun get(ctx: Context): AppDatabase =
30 47 instance ?: synchronized(this) {
31 48 instance ?: Room.databaseBuilder(
... ... @@ -33,7 +50,8 @@ abstract class AppDatabase : RoomDatabase() {
33 50 AppDatabase::class.java,
34 51 "lifelog.db",
35 52 )
36   - .fallbackToDestructiveMigration() // v0.3 dev: schema 大改,旧测试数据可丢
  53 + .addMigrations(MIGRATION_2_3)
  54 + .fallbackToDestructiveMigration() // 1→x 等无迁移路径仍兜底(dev 早期库可丢)
37 55 .build()
38 56 .also { instance = it }
39 57 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -152,6 +152,42 @@ interface SummaryDao {
152 152 }
153 153  
154 154 @Dao
  155 +interface ChatDao {
  156 + @Insert
  157 + suspend fun insertConversation(c: ChatConversation): Long
  158 +
  159 + @Insert
  160 + suspend fun insertMessage(m: ChatMessage): Long
  161 +
  162 + @Query("SELECT * FROM chat_conversations ORDER BY updatedAt DESC")
  163 + fun observeConversations(): Flow<List<ChatConversation>>
  164 +
  165 + @Query("SELECT * FROM chat_conversations WHERE id = :id")
  166 + suspend fun getConversation(id: Long): ChatConversation?
  167 +
  168 + @Query("SELECT * FROM chat_messages WHERE conversationId = :cid ORDER BY createdAt ASC, id ASC")
  169 + fun observeMessages(cid: Long): Flow<List<ChatMessage>>
  170 +
  171 + @Query("SELECT * FROM chat_messages WHERE conversationId = :cid ORDER BY createdAt ASC, id ASC")
  172 + suspend fun messagesOf(cid: Long): List<ChatMessage>
  173 +
  174 + @Query("UPDATE chat_conversations SET title = :title, updatedAt = :ts WHERE id = :id")
  175 + suspend fun updateTitle(id: Long, title: String, ts: Long)
  176 +
  177 + @Query("UPDATE chat_conversations SET targetId = :targetId, updatedAt = :ts WHERE id = :id")
  178 + suspend fun updateTarget(id: Long, targetId: String, ts: Long)
  179 +
  180 + @Query("UPDATE chat_conversations SET updatedAt = :ts WHERE id = :id")
  181 + suspend fun touch(id: Long, ts: Long)
  182 +
  183 + @Query("DELETE FROM chat_messages WHERE conversationId = :cid")
  184 + suspend fun deleteMessages(cid: Long)
  185 +
  186 + @Query("DELETE FROM chat_conversations WHERE id = :id")
  187 + suspend fun deleteConversation(id: Long)
  188 +}
  189 +
  190 +@Dao
155 191 interface SpeakerDao {
156 192 @Insert
157 193 suspend fun insert(p: SpeakerProfile): Long
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
... ... @@ -57,3 +57,26 @@ data class SpeakerProfile(
57 57 data class TranscriptFts(
58 58 val text: String,
59 59 )
  60 +
  61 +/** 一个对话会话(ChatGPT 左侧列表的一行)。targetId 记录最近一次用的聊天对象。 */
  62 +@Entity(tableName = "chat_conversations")
  63 +data class ChatConversation(
  64 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
  65 + val title: String,
  66 + /** ChatTarget.id:local-2b / local-4b / hermes */
  67 + val targetId: String,
  68 + val createdAt: Long,
  69 + val updatedAt: Long,
  70 +)
  71 +
  72 +/** 会话里的一条消息。role = "user" | "assistant"。 */
  73 +@Entity(tableName = "chat_messages")
  74 +data class ChatMessage(
  75 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
  76 + val conversationId: Long,
  77 + val role: String,
  78 + val content: String,
  79 + /** assistant 消息记录实际回复模型名,user 消息为 null */
  80 + val model: String? = null,
  81 + val createdAt: Long,
  82 +)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
... ... @@ -8,4 +8,5 @@ class Repository(ctx: Context) {
8 8 val transcripts = db.transcriptDao()
9 9 val summaries = db.summaryDao()
10 10 val speakers = db.speakerDao()
  11 + val chats = db.chatDao()
11 12 }
... ...