Commit 12ab73b93dcbb66ebdb81c4634e00e292107497e
1 parent
faf5436b
feat(lifelog/chat): lifelog 模式 RAG 问答 + 会话状态持久化 + 键盘修复
- LifelogRetriever:「lifelog 模式」开关下,发送前本地检索纪要 + 转写拼成 system context 注入(本地 2B/4B 和 Hermes 通用,不落库)。中文用 bigram + 重叠打分召回(FTS4 unicode61 对中文短语不命中),命中多者优先、抑制单 bigram 噪声 - Daos:加 timelineList / searchTextLike / summaries.recentList 三个 suspend 检索方法 - ChatViewModel:会话状态搬进 VM(随 Activity 存活)。修复切 tab 回来: 流式请求被取消(viewModelScope 不取消) / 变成新会话 / lifelog 开关重置—— currentConvId / selectedTargetId / lifelogMode 均持久化到 prefs,重启也记住 - 键盘 inset:Activity setDecorFitsSystemWindows(false) + manifest adjustResize + Scaffold imePadding,键盘弹出时整个界面(含底部 tab)抬到键盘上方、可见 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
5 changed files
with
379 additions
and
118 deletions
lifelog-recorder/app/src/main/AndroidManifest.xml
| ... | ... | @@ -25,6 +25,7 @@ |
| 25 | 25 | android:name=".MainActivity" |
| 26 | 26 | android:exported="true" |
| 27 | 27 | android:label="Lifelog" |
| 28 | + android:windowSoftInputMode="adjustResize" | |
| 28 | 29 | android:theme="@style/Theme.Lifelog"> |
| 29 | 30 | <intent-filter> |
| 30 | 31 | <action android:name="android.intent.action.MAIN" /> | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt
| ... | ... | @@ -22,6 +22,7 @@ import androidx.compose.material3.DropdownMenu |
| 22 | 22 | import androidx.compose.material3.DropdownMenuItem |
| 23 | 23 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 24 | 24 | import androidx.compose.material3.FilledTonalButton |
| 25 | +import androidx.compose.material3.FilterChip | |
| 25 | 26 | import androidx.compose.material3.HorizontalDivider |
| 26 | 27 | import androidx.compose.material3.Icon |
| 27 | 28 | import androidx.compose.material3.IconButton |
| ... | ... | @@ -54,119 +55,43 @@ import androidx.compose.ui.Modifier |
| 54 | 55 | import androidx.compose.ui.platform.LocalContext |
| 55 | 56 | import androidx.compose.ui.text.style.TextOverflow |
| 56 | 57 | import androidx.compose.ui.unit.dp |
| 57 | -import com.lemon.lifelog.chat.ChatClient | |
| 58 | 58 | import com.lemon.lifelog.chat.ChatConfig |
| 59 | 59 | import com.lemon.lifelog.chat.ChatTarget |
| 60 | -import com.lemon.lifelog.data.ChatConversation | |
| 61 | -import com.lemon.lifelog.data.ChatMessage | |
| 60 | +import com.lemon.lifelog.chat.ChatViewModel | |
| 62 | 61 | import com.lemon.lifelog.data.Repository |
| 63 | -import kotlinx.coroutines.Dispatchers | |
| 64 | 62 | import kotlinx.coroutines.launch |
| 65 | -import kotlinx.coroutines.withContext | |
| 66 | 63 | |
| 67 | 64 | @OptIn(ExperimentalMaterial3Api::class) |
| 68 | 65 | @Composable |
| 69 | 66 | internal fun ChatTab(repo: Repository) { |
| 70 | 67 | val ctx = LocalContext.current |
| 71 | 68 | val scope = rememberCoroutineScope() |
| 72 | - val client = remember { ChatClient() } | |
| 69 | + // 会话状态都在 ViewModel 里(随 Activity 存活),切 tab 回来不丢、流式不被取消。 | |
| 70 | + val vm: ChatViewModel = androidx.lifecycle.viewmodel.compose.viewModel() | |
| 73 | 71 | |
| 74 | - // 配置刷新计数器:改完端点设置后 bump 一下重新读 targets。 | |
| 75 | - var configRev by remember { mutableStateOf(0) } | |
| 76 | - val targets = remember(configRev) { ChatConfig.targets(ctx) } | |
| 72 | + val targets = remember(vm.configRev) { ChatConfig.targets(ctx) } | |
| 73 | + val selectedTarget = remember(vm.selectedTargetId, vm.configRev) { ChatConfig.targetById(ctx, vm.selectedTargetId) } | |
| 77 | 74 | |
| 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) } | |
| 75 | + val conversations by vm.repo.chats.observeConversations().collectAsState(initial = emptyList()) | |
| 83 | 76 | |
| 84 | 77 | // 当前会话的消息流。 |
| 85 | - val messages by remember(currentConvId) { | |
| 86 | - val cid = currentConvId | |
| 78 | + val messages by remember(vm.currentConvId) { | |
| 79 | + val cid = vm.currentConvId | |
| 87 | 80 | if (cid == null) kotlinx.coroutines.flow.flowOf(emptyList()) |
| 88 | - else repo.chats.observeMessages(cid) | |
| 81 | + else vm.repo.chats.observeMessages(cid) | |
| 89 | 82 | }.collectAsState(initial = emptyList()) |
| 90 | 83 | |
| 91 | - // 流式中的 assistant 回复(还没落库)。 | |
| 92 | - var streaming by remember { mutableStateOf(false) } | |
| 93 | - var streamingText by remember { mutableStateOf("") } | |
| 94 | - | |
| 95 | 84 | var showSettings by remember { mutableStateOf(false) } |
| 96 | - var input by remember { mutableStateOf("") } | |
| 97 | 85 | |
| 98 | 86 | val drawerState = rememberDrawerState(DrawerValue.Closed) |
| 99 | 87 | val listState = rememberLazyListState() |
| 100 | 88 | |
| 101 | 89 | // 新消息 / 流式更新时滚到底。 |
| 102 | - androidx.compose.runtime.LaunchedEffect(messages.size, streamingText) { | |
| 103 | - val total = messages.size + if (streaming) 1 else 0 | |
| 90 | + androidx.compose.runtime.LaunchedEffect(messages.size, vm.streamingText) { | |
| 91 | + val total = messages.size + if (vm.streaming) 1 else 0 | |
| 104 | 92 | if (total > 0) listState.animateScrollToItem(total - 1) |
| 105 | 93 | } |
| 106 | 94 | |
| 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 | 95 | ModalNavigationDrawer( |
| 171 | 96 | drawerState = drawerState, |
| 172 | 97 | drawerContent = { |
| ... | ... | @@ -175,8 +100,7 @@ internal fun ChatTab(repo: Repository) { |
| 175 | 100 | Text("对话历史", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp)) |
| 176 | 101 | FilledTonalButton( |
| 177 | 102 | onClick = { |
| 178 | - currentConvId = null | |
| 179 | - streamingText = "" | |
| 103 | + vm.newConversation() | |
| 180 | 104 | scope.launch { drawerState.close() } |
| 181 | 105 | }, |
| 182 | 106 | modifier = Modifier.fillMaxWidth(), |
| ... | ... | @@ -197,23 +121,15 @@ internal fun ChatTab(repo: Repository) { |
| 197 | 121 | overflow = TextOverflow.Ellipsis, |
| 198 | 122 | ) |
| 199 | 123 | }, |
| 200 | - selected = conv.id == currentConvId, | |
| 124 | + selected = conv.id == vm.currentConvId, | |
| 201 | 125 | onClick = { |
| 202 | - currentConvId = conv.id | |
| 203 | - selectedTargetId = conv.targetId | |
| 204 | - streamingText = "" | |
| 126 | + vm.openConversation(conv.id, conv.targetId) | |
| 205 | 127 | scope.launch { drawerState.close() } |
| 206 | 128 | }, |
| 207 | 129 | 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 = "删除") } | |
| 130 | + IconButton(onClick = { vm.deleteConversation(conv.id) }) { | |
| 131 | + Icon(Icons.Filled.Delete, contentDescription = "删除") | |
| 132 | + } | |
| 217 | 133 | }, |
| 218 | 134 | ) |
| 219 | 135 | } |
| ... | ... | @@ -226,13 +142,7 @@ internal fun ChatTab(repo: Repository) { |
| 226 | 142 | // 顶栏:菜单 + 聊天对象选择 + 设置。 |
| 227 | 143 | TopAppBar( |
| 228 | 144 | 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 | - } | |
| 145 | + vm.selectTarget(picked.id) | |
| 236 | 146 | } }, |
| 237 | 147 | navigationIcon = { |
| 238 | 148 | IconButton(onClick = { scope.launch { drawerState.open() } }) { |
| ... | ... | @@ -240,7 +150,7 @@ internal fun ChatTab(repo: Repository) { |
| 240 | 150 | } |
| 241 | 151 | }, |
| 242 | 152 | actions = { |
| 243 | - IconButton(onClick = { currentConvId = null; streamingText = "" }) { | |
| 153 | + IconButton(onClick = { vm.newConversation() }) { | |
| 244 | 154 | Icon(Icons.Filled.Add, contentDescription = "新对话") |
| 245 | 155 | } |
| 246 | 156 | IconButton(onClick = { showSettings = true }) { |
| ... | ... | @@ -251,7 +161,7 @@ internal fun ChatTab(repo: Repository) { |
| 251 | 161 | |
| 252 | 162 | // 消息列表。 |
| 253 | 163 | Box(modifier = Modifier.weight(1f).fillMaxWidth()) { |
| 254 | - if (messages.isEmpty() && !streaming) { | |
| 164 | + if (messages.isEmpty() && !vm.streaming) { | |
| 255 | 165 | Column( |
| 256 | 166 | modifier = Modifier.fillMaxSize().padding(24.dp), |
| 257 | 167 | verticalArrangement = Arrangement.Center, |
| ... | ... | @@ -273,15 +183,32 @@ internal fun ChatTab(repo: Repository) { |
| 273 | 183 | verticalArrangement = Arrangement.spacedBy(8.dp), |
| 274 | 184 | ) { |
| 275 | 185 | items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) } |
| 276 | - if (streaming) { | |
| 186 | + if (vm.streaming) { | |
| 277 | 187 | item(key = "streaming") { |
| 278 | - MessageBubble("assistant", streamingText.ifEmpty { "…" }, inProgress = true) | |
| 188 | + MessageBubble("assistant", vm.streamingText.ifEmpty { "…" }, inProgress = true) | |
| 279 | 189 | } |
| 280 | 190 | } |
| 281 | 191 | } |
| 282 | 192 | } |
| 283 | 193 | } |
| 284 | 194 | |
| 195 | + // lifelog 模式开关。 | |
| 196 | + Row( | |
| 197 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), | |
| 198 | + verticalAlignment = Alignment.CenterVertically, | |
| 199 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 200 | + ) { | |
| 201 | + FilterChip( | |
| 202 | + selected = vm.lifelogMode, | |
| 203 | + onClick = { vm.setLifelog(!vm.lifelogMode) }, | |
| 204 | + label = { Text("🧠 lifelog 模式") }, | |
| 205 | + ) | |
| 206 | + Text( | |
| 207 | + if (vm.lifelogMode) "回答会带上你的纪要/转写检索" else "纯聊天,不读 lifelog", | |
| 208 | + style = MaterialTheme.typography.labelSmall, | |
| 209 | + ) | |
| 210 | + } | |
| 211 | + | |
| 285 | 212 | HorizontalDivider() |
| 286 | 213 | |
| 287 | 214 | // 输入区。 |
| ... | ... | @@ -291,14 +218,14 @@ internal fun ChatTab(repo: Repository) { |
| 291 | 218 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 292 | 219 | ) { |
| 293 | 220 | OutlinedTextField( |
| 294 | - value = input, | |
| 295 | - onValueChange = { input = it }, | |
| 221 | + value = vm.input, | |
| 222 | + onValueChange = { vm.input = it }, | |
| 296 | 223 | modifier = Modifier.weight(1f), |
| 297 | 224 | placeholder = { Text("发消息给 ${selectedTarget.label}…") }, |
| 298 | 225 | maxLines = 5, |
| 299 | 226 | ) |
| 300 | - IconButton(onClick = { send() }, enabled = input.isNotBlank() && !streaming) { | |
| 301 | - if (streaming) { | |
| 227 | + IconButton(onClick = { vm.send() }, enabled = vm.input.isNotBlank() && !vm.streaming) { | |
| 228 | + if (vm.streaming) { | |
| 302 | 229 | CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp) |
| 303 | 230 | } else { |
| 304 | 231 | Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") |
| ... | ... | @@ -316,7 +243,7 @@ internal fun ChatTab(repo: Repository) { |
| 316 | 243 | onDismiss = { showSettings = false }, |
| 317 | 244 | onSave = { base, m2, m4 -> |
| 318 | 245 | ChatConfig.save(ctx, base, m2, m4) |
| 319 | - configRev++ | |
| 246 | + vm.bumpConfig() | |
| 320 | 247 | showSettings = false |
| 321 | 248 | }, |
| 322 | 249 | ) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt
0 → 100644
| 1 | +package com.lemon.lifelog.chat | |
| 2 | + | |
| 3 | +import android.app.Application | |
| 4 | +import android.content.Context | |
| 5 | +import androidx.compose.runtime.getValue | |
| 6 | +import androidx.compose.runtime.mutableStateOf | |
| 7 | +import androidx.compose.runtime.setValue | |
| 8 | +import androidx.lifecycle.AndroidViewModel | |
| 9 | +import androidx.lifecycle.viewModelScope | |
| 10 | +import com.lemon.lifelog.data.ChatConversation | |
| 11 | +import com.lemon.lifelog.data.ChatMessage | |
| 12 | +import com.lemon.lifelog.data.Repository | |
| 13 | +import kotlinx.coroutines.Dispatchers | |
| 14 | +import kotlinx.coroutines.launch | |
| 15 | +import kotlinx.coroutines.withContext | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 聊天会话状态 + 发送逻辑的宿主。放在 ViewModel 里(随 Activity 存活)而不是 ChatTab | |
| 19 | + * composable 里,是为了:切到别的 tab 再回来时—— | |
| 20 | + * 1. 正在跑的流式请求不被取消(viewModelScope,不随 ChatTab 离开 composition 而 cancel); | |
| 21 | + * 2. 当前会话 / 选中模型 / lifelog 开关不丢(且持久化到 prefs,连重启都记住)。 | |
| 22 | + */ | |
| 23 | +class ChatViewModel(app: Application) : AndroidViewModel(app) { | |
| 24 | + val repo = Repository(app) | |
| 25 | + private val client = ChatClient() | |
| 26 | + private val prefs = app.getSharedPreferences(ChatConfig.PREFS, Context.MODE_PRIVATE) | |
| 27 | + | |
| 28 | + var currentConvId by mutableStateOf(prefs.getLong(K_CONV, -1L).takeIf { it > 0 }) | |
| 29 | + private set | |
| 30 | + var selectedTargetId by mutableStateOf( | |
| 31 | + prefs.getString(K_TARGET, null) ?: ChatConfig.targets(app).first().id, | |
| 32 | + ) | |
| 33 | + private set | |
| 34 | + var lifelogMode by mutableStateOf(prefs.getBoolean(K_LIFELOG, false)) | |
| 35 | + private set | |
| 36 | + | |
| 37 | + var input by mutableStateOf("") | |
| 38 | + var streaming by mutableStateOf(false) | |
| 39 | + private set | |
| 40 | + var streamingText by mutableStateOf("") | |
| 41 | + private set | |
| 42 | + var configRev by mutableStateOf(0) | |
| 43 | + private set | |
| 44 | + | |
| 45 | + fun setLifelog(on: Boolean) { | |
| 46 | + lifelogMode = on | |
| 47 | + prefs.edit().putBoolean(K_LIFELOG, on).apply() | |
| 48 | + } | |
| 49 | + | |
| 50 | + fun selectTarget(id: String) { | |
| 51 | + selectedTargetId = id | |
| 52 | + prefs.edit().putString(K_TARGET, id).apply() | |
| 53 | + val cid = currentConvId | |
| 54 | + if (cid != null) viewModelScope.launch { | |
| 55 | + withContext(Dispatchers.IO) { repo.chats.updateTarget(cid, id, System.currentTimeMillis()) } | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + fun openConversation(id: Long, targetId: String) { | |
| 60 | + currentConvId = id | |
| 61 | + prefs.edit().putLong(K_CONV, id).apply() | |
| 62 | + selectedTargetId = targetId | |
| 63 | + prefs.edit().putString(K_TARGET, targetId).apply() | |
| 64 | + streamingText = "" | |
| 65 | + } | |
| 66 | + | |
| 67 | + fun newConversation() { | |
| 68 | + currentConvId = null | |
| 69 | + prefs.edit().remove(K_CONV).apply() | |
| 70 | + streamingText = "" | |
| 71 | + } | |
| 72 | + | |
| 73 | + fun bumpConfig() { | |
| 74 | + configRev++ | |
| 75 | + } | |
| 76 | + | |
| 77 | + fun deleteConversation(id: Long) { | |
| 78 | + viewModelScope.launch { | |
| 79 | + withContext(Dispatchers.IO) { | |
| 80 | + repo.chats.deleteMessages(id) | |
| 81 | + repo.chats.deleteConversation(id) | |
| 82 | + } | |
| 83 | + if (currentConvId == id) newConversation() | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + fun send() { | |
| 88 | + val text = input.trim() | |
| 89 | + if (text.isEmpty() || streaming) return | |
| 90 | + input = "" | |
| 91 | + val app = getApplication<Application>() | |
| 92 | + val target = ChatConfig.targetById(app, selectedTargetId) | |
| 93 | + val useLifelog = lifelogMode | |
| 94 | + viewModelScope.launch { | |
| 95 | + val now = System.currentTimeMillis() | |
| 96 | + var convId = currentConvId | |
| 97 | + if (convId == null) { | |
| 98 | + convId = withContext(Dispatchers.IO) { | |
| 99 | + repo.chats.insertConversation( | |
| 100 | + ChatConversation(title = text.take(20), targetId = target.id, createdAt = now, updatedAt = now), | |
| 101 | + ) | |
| 102 | + } | |
| 103 | + currentConvId = convId | |
| 104 | + prefs.edit().putLong(K_CONV, convId!!).apply() | |
| 105 | + } | |
| 106 | + val cid = convId!! | |
| 107 | + withContext(Dispatchers.IO) { | |
| 108 | + repo.chats.insertMessage(ChatMessage(conversationId = cid, role = "user", content = text, createdAt = now)) | |
| 109 | + repo.chats.touch(cid, now) | |
| 110 | + } | |
| 111 | + val history = withContext(Dispatchers.IO) { repo.chats.messagesOf(cid) }.map { it.role to it.content } | |
| 112 | + | |
| 113 | + streaming = true | |
| 114 | + streamingText = "" | |
| 115 | + try { | |
| 116 | + val messagesToSend = if (useLifelog) { | |
| 117 | + val c = LifelogRetriever.buildContext(repo, text) | |
| 118 | + if (c != null) listOf("system" to c) + history else history | |
| 119 | + } else { | |
| 120 | + history | |
| 121 | + } | |
| 122 | + val full = client.stream(target, messagesToSend) { delta -> streamingText += delta } | |
| 123 | + withContext(Dispatchers.IO) { | |
| 124 | + repo.chats.insertMessage( | |
| 125 | + ChatMessage( | |
| 126 | + conversationId = cid, role = "assistant", | |
| 127 | + content = full.ifBlank { "(空回复)" }, model = target.model, | |
| 128 | + createdAt = System.currentTimeMillis(), | |
| 129 | + ), | |
| 130 | + ) | |
| 131 | + repo.chats.touch(cid, System.currentTimeMillis()) | |
| 132 | + } | |
| 133 | + } catch (t: Throwable) { | |
| 134 | + withContext(Dispatchers.IO) { | |
| 135 | + repo.chats.insertMessage( | |
| 136 | + ChatMessage( | |
| 137 | + conversationId = cid, role = "assistant", | |
| 138 | + content = "⚠ 出错:${t.message ?: t.toString()}", model = target.model, | |
| 139 | + createdAt = System.currentTimeMillis(), | |
| 140 | + ), | |
| 141 | + ) | |
| 142 | + } | |
| 143 | + } finally { | |
| 144 | + streaming = false | |
| 145 | + streamingText = "" | |
| 146 | + } | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + companion object { | |
| 151 | + private const val K_LIFELOG = "lifelog_mode" | |
| 152 | + private const val K_TARGET = "selected_target" | |
| 153 | + private const val K_CONV = "current_conv" | |
| 154 | + } | |
| 155 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogRetriever.kt
0 → 100644
| 1 | +package com.lemon.lifelog.chat | |
| 2 | + | |
| 3 | +import android.util.Log | |
| 4 | +import com.lemon.lifelog.data.Repository | |
| 5 | +import com.lemon.lifelog.data.SegmentRow | |
| 6 | +import kotlinx.coroutines.Dispatchers | |
| 7 | +import kotlinx.coroutines.withContext | |
| 8 | +import java.text.SimpleDateFormat | |
| 9 | +import java.util.Date | |
| 10 | +import java.util.Locale | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 「lifelog 模式」的本地检索(RAG)。聊天时把跟用户问题相关的 lifelog 内容 | |
| 14 | + * 拼成一段 system context 注入到模型请求里——本地 2B/4B 和 Hermes 通用。 | |
| 15 | + * | |
| 16 | + * 检索三路(高信号优先): | |
| 17 | + * 1. 会话纪要(已 LLM 压缩,覆盖面广、最省 token)——backbone。 | |
| 18 | + * 2. 关键词子串命中的转写片段(LIKE,对中文可靠)——精确细节。 | |
| 19 | + * 3. 最近时间轴片段——兜底「最近/今天」类问题。 | |
| 20 | + */ | |
| 21 | +object LifelogRetriever { | |
| 22 | + private val FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) | |
| 23 | + private val TS_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) | |
| 24 | + | |
| 25 | + /** 注入 context 的字符上限(≈3–4k 中文 token,兼顾本地 4B 的上下文窗口)。 */ | |
| 26 | + private const val MAX_CHARS = 6000 | |
| 27 | + private const val SUMMARY_LIMIT = 20 | |
| 28 | + private const val SUMMARY_EACH_CHARS = 400 | |
| 29 | + private const val HITS_PER_TERM = 12 | |
| 30 | + private const val HITS_TOTAL = 30 | |
| 31 | + private const val RECENT_LIMIT = 20 | |
| 32 | + private const val MAX_TERMS = 16 | |
| 33 | + | |
| 34 | + // 纯虚词/高频功能 bigram:命中无意义、只会污染候选,检索时丢掉。 | |
| 35 | + private val STOP_BIGRAMS = setOf( | |
| 36 | + "什么", "怎么", "这个", "那个", "一下", "这样", "那样", "可以", "就是", "然后", | |
| 37 | + "因为", "所以", "但是", "如果", "的话", "我们", "你们", "他们", "今天", "昨天", | |
| 38 | + "最近", "这周", "本周", "上周", "有没", "没有", "知道", "觉得", "可能", | |
| 39 | + ) | |
| 40 | + | |
| 41 | + /** 给定用户问题,返回要注入的 system context;无任何数据返回 null。 */ | |
| 42 | + suspend fun buildContext(repo: Repository, question: String): String? = withContext(Dispatchers.IO) { | |
| 43 | + val summaries = repo.summaries.recentList(SUMMARY_LIMIT) | |
| 44 | + val terms = searchTerms(question) | |
| 45 | + | |
| 46 | + // bigram 召回 + 重叠打分:一段转写命中越多 query bigram 越相关, | |
| 47 | + // 单 bigram 噪声(如某段只含"内容")被排到尾部丢掉。 | |
| 48 | + val cand = LinkedHashMap<Long, SegmentRow>() | |
| 49 | + for (t in terms) { | |
| 50 | + repo.segments.searchTextLike(t, HITS_PER_TERM).forEach { row -> | |
| 51 | + if (row.text != null) cand.putIfAbsent(row.id, row) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + val scored = cand.values | |
| 55 | + .map { row -> row to terms.count { row.text!!.contains(it) } } | |
| 56 | + .filter { it.second > 0 } | |
| 57 | + // 有 ≥2 命中的强匹配时,丢掉只命中 1 个 bigram 的弱噪声。 | |
| 58 | + val minScore = if (scored.any { it.second >= 2 }) 2 else 1 | |
| 59 | + val hits = scored | |
| 60 | + .filter { it.second >= minScore } | |
| 61 | + .sortedWith(compareByDescending<Pair<SegmentRow, Int>> { it.second }.thenByDescending { it.first.startTs }) | |
| 62 | + .take(HITS_TOTAL) | |
| 63 | + .map { it.first } | |
| 64 | + val recent = repo.segments.timelineList(RECENT_LIMIT).filter { it.text != null } | |
| 65 | + | |
| 66 | + if (summaries.isEmpty() && hits.isEmpty() && recent.isEmpty()) return@withContext null | |
| 67 | + | |
| 68 | + val sb = StringBuilder() | |
| 69 | + sb.appendLine( | |
| 70 | + "你是用户的私人 lifelog 助手。lifelog 是用户 24/7 录音转写出来的个人记忆库。" + | |
| 71 | + "下面是从中检索出的相关内容,请**只依据这些内容**回答用户的问题;" + | |
| 72 | + "若不足以回答,明确说「我的 lifelog 里没找到相关内容」,不要编造。" + | |
| 73 | + "引用具体事实时可带上 [时间戳]。", | |
| 74 | + ) | |
| 75 | + sb.appendLine() | |
| 76 | + | |
| 77 | + if (summaries.isNotEmpty()) { | |
| 78 | + sb.appendLine("【会话纪要】(共 ${summaries.size} 篇,时间倒序)") | |
| 79 | + summaries.forEach { s -> | |
| 80 | + val range = "${FMT.format(Date(s.windowStartTs))}–${FMT.format(Date(s.windowEndTs))}" | |
| 81 | + val body = s.markdown.trim().replace("\n", " ").take(SUMMARY_EACH_CHARS) | |
| 82 | + sb.appendLine("- [$range] $body") | |
| 83 | + } | |
| 84 | + sb.appendLine() | |
| 85 | + } | |
| 86 | + | |
| 87 | + if (hits.isNotEmpty()) { | |
| 88 | + sb.appendLine("【相关转写片段】(按问题关键词命中,相关度优先)") | |
| 89 | + hits.forEach { sb.appendLine(formatSeg(it)) } | |
| 90 | + sb.appendLine() | |
| 91 | + } | |
| 92 | + | |
| 93 | + if (recent.isNotEmpty()) { | |
| 94 | + sb.appendLine("【最近转写】(最近 ${recent.size} 段,供「最近/今天」类问题参考)") | |
| 95 | + recent.forEach { sb.appendLine(formatSeg(it)) } | |
| 96 | + } | |
| 97 | + | |
| 98 | + var ctx = sb.toString() | |
| 99 | + if (ctx.length > MAX_CHARS) ctx = ctx.take(MAX_CHARS) + "\n…(上下文已截断)" | |
| 100 | + Log.i( | |
| 101 | + "LifelogRAG", | |
| 102 | + "q=\"${question.take(40)}\" terms=$terms " + | |
| 103 | + "纪要=${summaries.size} 命中=${hits.size} 最近=${recent.size} chars=${ctx.length}", | |
| 104 | + ) | |
| 105 | + ctx | |
| 106 | + } | |
| 107 | + | |
| 108 | + private fun formatSeg(r: SegmentRow): String { | |
| 109 | + val who = r.speakerName ?: "未知" | |
| 110 | + return "[${TS_FMT.format(Date(r.startTs))}] $who:${r.text?.trim().orEmpty()}" | |
| 111 | + } | |
| 112 | + | |
| 113 | + /** | |
| 114 | + * 把问题切成检索词:英文/数字整词保留;中文按**重叠 bigram**切。 | |
| 115 | + * 当前 FTS4 unicode61 对中文把整段 CJK 当一个 token、短语检索不命中; | |
| 116 | + * bigram + LIKE 才能让"黑神话悟空"这种被真正召回(黑神/神话/悟空…多 bigram 命中)。 | |
| 117 | + */ | |
| 118 | + private fun searchTerms(question: String): List<String> { | |
| 119 | + val cleaned = question.lowercase() | |
| 120 | + .replace(Regex("[\\p{Punct},。!?、;:“”‘’()《》【】…—~·\\s]+"), " ") | |
| 121 | + val terms = LinkedHashSet<String>() | |
| 122 | + for (tok in cleaned.split(" ")) { | |
| 123 | + if (tok.isBlank()) continue | |
| 124 | + if (tok.all { it.code < 128 }) { | |
| 125 | + if (tok.length >= 2) terms += tok // 英文/数字整词 | |
| 126 | + } else { | |
| 127 | + val s = tok.filter { !it.isWhitespace() } | |
| 128 | + for (i in 0 until s.length - 1) { | |
| 129 | + val bg = s.substring(i, i + 2) // CJK 重叠 bigram | |
| 130 | + if (bg !in STOP_BIGRAMS) terms += bg | |
| 131 | + } | |
| 132 | + } | |
| 133 | + if (terms.size >= MAX_TERMS) break | |
| 134 | + } | |
| 135 | + return terms.take(MAX_TERMS).toList() | |
| 136 | + } | |
| 137 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -101,6 +101,43 @@ interface SegmentDao { |
| 101 | 101 | ) |
| 102 | 102 | fun observeTimeline(limit: Int = 100): Flow<List<SegmentRow>> |
| 103 | 103 | |
| 104 | + /** 一次性取最近时间轴(RAG 检索用,非 Flow)。 */ | |
| 105 | + @Query( | |
| 106 | + """ | |
| 107 | + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | |
| 108 | + t.text AS text, s.audioPath AS audioPath, | |
| 109 | + s.speakerId AS speakerId, s.speakerSim AS speakerSim, | |
| 110 | + sp.name AS speakerName | |
| 111 | + FROM segments s | |
| 112 | + LEFT JOIN transcripts t ON t.segmentId = s.id | |
| 113 | + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | |
| 114 | + ORDER BY s.startTs DESC | |
| 115 | + LIMIT :limit | |
| 116 | + """, | |
| 117 | + ) | |
| 118 | + suspend fun timelineList(limit: Int): List<SegmentRow> | |
| 119 | + | |
| 120 | + /** | |
| 121 | + * 中文子串检索(RAG 用):LIKE '%kw%'。 | |
| 122 | + * 当前 FTS4 是 unicode61,对中文把整段 CJK 当一个 token、短语检索几乎不命中; | |
| 123 | + * 个人库行数少,直接 LIKE 子串扫最稳。 | |
| 124 | + */ | |
| 125 | + @Query( | |
| 126 | + """ | |
| 127 | + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | |
| 128 | + t.text AS text, s.audioPath AS audioPath, | |
| 129 | + s.speakerId AS speakerId, s.speakerSim AS speakerSim, | |
| 130 | + sp.name AS speakerName | |
| 131 | + FROM segments s | |
| 132 | + INNER JOIN transcripts t ON t.segmentId = s.id | |
| 133 | + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | |
| 134 | + WHERE t.text LIKE '%' || :kw || '%' | |
| 135 | + ORDER BY s.startTs DESC | |
| 136 | + LIMIT :limit | |
| 137 | + """, | |
| 138 | + ) | |
| 139 | + suspend fun searchTextLike(kw: String, limit: Int): List<SegmentRow> | |
| 140 | + | |
| 104 | 141 | /** Full-text search via FTS4 virtual table. Falls back to LIKE if MATCH query is empty. */ |
| 105 | 142 | @Query( |
| 106 | 143 | """ |
| ... | ... | @@ -144,6 +181,10 @@ interface SummaryDao { |
| 144 | 181 | @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n") |
| 145 | 182 | fun recent(n: Int): Flow<List<Summary>> |
| 146 | 183 | |
| 184 | + /** 一次性取最近 n 篇纪要(RAG 检索用,非 Flow)。 */ | |
| 185 | + @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n") | |
| 186 | + suspend fun recentList(n: Int): List<Summary> | |
| 187 | + | |
| 147 | 188 | @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC") |
| 148 | 189 | suspend fun all(): List<Summary> |
| 149 | 190 | ... | ... |