Commit fd8e772a907e67303bc10d8e1ada10727b4cb343
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>
Showing
10 changed files
with
722 additions
and
6 deletions
lifelog-recorder/.gitignore
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,7 +109,8 @@ class MainActivity : ComponentActivity() { | ||
| 109 | } | 109 | } |
| 110 | 110 | ||
| 111 | private enum class Tab(val label: String, val icon: String) { | 111 | private enum class Tab(val label: String, val icon: String) { |
| 112 | - Home("首页", "●"), | 112 | + Home("录音", "⏺"), |
| 113 | + Chat("对话", "💬"), | ||
| 113 | Timeline("时间轴", "≣"), | 114 | Timeline("时间轴", "≣"), |
| 114 | Speakers("声纹", "👤"), | 115 | Speakers("声纹", "👤"), |
| 115 | Summaries("纪要", "✎"), | 116 | Summaries("纪要", "✎"), |
| @@ -157,7 +158,8 @@ private fun MainScreen() { | @@ -157,7 +158,8 @@ private fun MainScreen() { | ||
| 157 | ) { inner -> | 158 | ) { inner -> |
| 158 | Box(modifier = Modifier.padding(inner)) { | 159 | Box(modifier = Modifier.padding(inner)) { |
| 159 | when (tab) { | 160 | when (tab) { |
| 160 | - Tab.Home -> HomeTab(activity) | 161 | + Tab.Home -> HomeTab(activity, repo, onOpenTimeline = { tab = Tab.Timeline }) |
| 162 | + Tab.Chat -> ChatTab(repo) | ||
| 161 | Tab.Timeline -> TimelineTab( | 163 | Tab.Timeline -> TimelineTab( |
| 162 | repo = repo, | 164 | repo = repo, |
| 163 | speakers = speakers, | 165 | speakers = speakers, |
| @@ -191,7 +193,7 @@ private fun MainScreen() { | @@ -191,7 +193,7 @@ private fun MainScreen() { | ||
| 191 | // ============================================================ Home tab | 193 | // ============================================================ Home tab |
| 192 | 194 | ||
| 193 | @Composable | 195 | @Composable |
| 194 | -private fun HomeTab(activity: Context) { | 196 | +private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit) { |
| 195 | val running by RecorderState.isRunning.collectAsState() | 197 | val running by RecorderState.isRunning.collectAsState() |
| 196 | val paused by RecorderState.isPaused.collectAsState() | 198 | val paused by RecorderState.isPaused.collectAsState() |
| 197 | val status by RecorderState.status.collectAsState() | 199 | val status by RecorderState.status.collectAsState() |
| @@ -199,6 +201,7 @@ private fun HomeTab(activity: Context) { | @@ -199,6 +201,7 @@ private fun HomeTab(activity: Context) { | ||
| 199 | val lastAudioMs by RecorderState.lastAudioMs.collectAsState() | 201 | val lastAudioMs by RecorderState.lastAudioMs.collectAsState() |
| 200 | val lastWallMs by RecorderState.lastWallMs.collectAsState() | 202 | val lastWallMs by RecorderState.lastWallMs.collectAsState() |
| 201 | val scope = rememberCoroutineScope() | 203 | val scope = rememberCoroutineScope() |
| 204 | + val recent by repo.segments.observeTimeline(5).collectAsState(initial = emptyList()) | ||
| 202 | 205 | ||
| 203 | var asrStatus by remember { mutableStateOf("ASR: idle") } | 206 | var asrStatus by remember { mutableStateOf("ASR: idle") } |
| 204 | var asrLog by remember { mutableStateOf("") } | 207 | var asrLog by remember { mutableStateOf("") } |
| @@ -266,6 +269,28 @@ private fun HomeTab(activity: Context) { | @@ -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 | // Battery whitelist. | 294 | // Battery whitelist. |
| 270 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | 295 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 271 | Text( | 296 | Text( |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt
| @@ -30,7 +30,8 @@ class HermesClient( | @@ -30,7 +30,8 @@ class HermesClient( | ||
| 30 | 30 | ||
| 31 | private val http: OkHttpClient = OkHttpClient.Builder() | 31 | private val http: OkHttpClient = OkHttpClient.Builder() |
| 32 | .connectTimeout(10, TimeUnit.SECONDS) | 32 | .connectTimeout(10, TimeUnit.SECONDS) |
| 33 | - .readTimeout(120, TimeUnit.SECONDS) // Kimi 大段总结可以慢 | 33 | + // 本地 llama-server 处理长会话(数千 token prompt + 热降频)可能要几分钟。 |
| 34 | + .readTimeout(600, TimeUnit.SECONDS) | ||
| 34 | .writeTimeout(30, TimeUnit.SECONDS) | 35 | .writeTimeout(30, TimeUnit.SECONDS) |
| 35 | .build() | 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,6 +4,8 @@ import android.content.Context | ||
| 4 | import androidx.room.Database | 4 | import androidx.room.Database |
| 5 | import androidx.room.Room | 5 | import androidx.room.Room |
| 6 | import androidx.room.RoomDatabase | 6 | import androidx.room.RoomDatabase |
| 7 | +import androidx.room.migration.Migration | ||
| 8 | +import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 7 | 9 | ||
| 8 | @Database( | 10 | @Database( |
| 9 | entities = [ | 11 | entities = [ |
| @@ -12,8 +14,10 @@ import androidx.room.RoomDatabase | @@ -12,8 +14,10 @@ import androidx.room.RoomDatabase | ||
| 12 | Summary::class, | 14 | Summary::class, |
| 13 | SpeakerProfile::class, | 15 | SpeakerProfile::class, |
| 14 | TranscriptFts::class, | 16 | TranscriptFts::class, |
| 17 | + ChatConversation::class, | ||
| 18 | + ChatMessage::class, | ||
| 15 | ], | 19 | ], |
| 16 | - version = 2, | 20 | + version = 3, |
| 17 | exportSchema = false, | 21 | exportSchema = false, |
| 18 | ) | 22 | ) |
| 19 | abstract class AppDatabase : RoomDatabase() { | 23 | abstract class AppDatabase : RoomDatabase() { |
| @@ -21,11 +25,24 @@ abstract class AppDatabase : RoomDatabase() { | @@ -21,11 +25,24 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 21 | abstract fun transcriptDao(): TranscriptDao | 25 | abstract fun transcriptDao(): TranscriptDao |
| 22 | abstract fun summaryDao(): SummaryDao | 26 | abstract fun summaryDao(): SummaryDao |
| 23 | abstract fun speakerDao(): SpeakerDao | 27 | abstract fun speakerDao(): SpeakerDao |
| 28 | + abstract fun chatDao(): ChatDao | ||
| 24 | 29 | ||
| 25 | companion object { | 30 | companion object { |
| 26 | @Volatile | 31 | @Volatile |
| 27 | private var instance: AppDatabase? = null | 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 | fun get(ctx: Context): AppDatabase = | 46 | fun get(ctx: Context): AppDatabase = |
| 30 | instance ?: synchronized(this) { | 47 | instance ?: synchronized(this) { |
| 31 | instance ?: Room.databaseBuilder( | 48 | instance ?: Room.databaseBuilder( |
| @@ -33,7 +50,8 @@ abstract class AppDatabase : RoomDatabase() { | @@ -33,7 +50,8 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 33 | AppDatabase::class.java, | 50 | AppDatabase::class.java, |
| 34 | "lifelog.db", | 51 | "lifelog.db", |
| 35 | ) | 52 | ) |
| 36 | - .fallbackToDestructiveMigration() // v0.3 dev: schema 大改,旧测试数据可丢 | 53 | + .addMigrations(MIGRATION_2_3) |
| 54 | + .fallbackToDestructiveMigration() // 1→x 等无迁移路径仍兜底(dev 早期库可丢) | ||
| 37 | .build() | 55 | .build() |
| 38 | .also { instance = it } | 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,6 +152,42 @@ interface SummaryDao { | ||
| 152 | } | 152 | } |
| 153 | 153 | ||
| 154 | @Dao | 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 | interface SpeakerDao { | 191 | interface SpeakerDao { |
| 156 | @Insert | 192 | @Insert |
| 157 | suspend fun insert(p: SpeakerProfile): Long | 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,3 +57,26 @@ data class SpeakerProfile( | ||
| 57 | data class TranscriptFts( | 57 | data class TranscriptFts( |
| 58 | val text: String, | 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,4 +8,5 @@ class Repository(ctx: Context) { | ||
| 8 | val transcripts = db.transcriptDao() | 8 | val transcripts = db.transcriptDao() |
| 9 | val summaries = db.summaryDao() | 9 | val summaries = db.summaryDao() |
| 10 | val speakers = db.speakerDao() | 10 | val speakers = db.speakerDao() |
| 11 | + val chats = db.chatDao() | ||
| 11 | } | 12 | } |