Commit 12ab73b93dcbb66ebdb81c4634e00e292107497e

Authored by yibo
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>
lifelog-recorder/app/src/main/AndroidManifest.xml
@@ -25,6 +25,7 @@ @@ -25,6 +25,7 @@
25 android:name=".MainActivity" 25 android:name=".MainActivity"
26 android:exported="true" 26 android:exported="true"
27 android:label="Lifelog" 27 android:label="Lifelog"
  28 + android:windowSoftInputMode="adjustResize"
28 android:theme="@style/Theme.Lifelog"> 29 android:theme="@style/Theme.Lifelog">
29 <intent-filter> 30 <intent-filter>
30 <action android:name="android.intent.action.MAIN" /> 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,6 +22,7 @@ import androidx.compose.material3.DropdownMenu
22 import androidx.compose.material3.DropdownMenuItem 22 import androidx.compose.material3.DropdownMenuItem
23 import androidx.compose.material3.ExperimentalMaterial3Api 23 import androidx.compose.material3.ExperimentalMaterial3Api
24 import androidx.compose.material3.FilledTonalButton 24 import androidx.compose.material3.FilledTonalButton
  25 +import androidx.compose.material3.FilterChip
25 import androidx.compose.material3.HorizontalDivider 26 import androidx.compose.material3.HorizontalDivider
26 import androidx.compose.material3.Icon 27 import androidx.compose.material3.Icon
27 import androidx.compose.material3.IconButton 28 import androidx.compose.material3.IconButton
@@ -54,119 +55,43 @@ import androidx.compose.ui.Modifier @@ -54,119 +55,43 @@ import androidx.compose.ui.Modifier
54 import androidx.compose.ui.platform.LocalContext 55 import androidx.compose.ui.platform.LocalContext
55 import androidx.compose.ui.text.style.TextOverflow 56 import androidx.compose.ui.text.style.TextOverflow
56 import androidx.compose.ui.unit.dp 57 import androidx.compose.ui.unit.dp
57 -import com.lemon.lifelog.chat.ChatClient  
58 import com.lemon.lifelog.chat.ChatConfig 58 import com.lemon.lifelog.chat.ChatConfig
59 import com.lemon.lifelog.chat.ChatTarget 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 import com.lemon.lifelog.data.Repository 61 import com.lemon.lifelog.data.Repository
63 -import kotlinx.coroutines.Dispatchers  
64 import kotlinx.coroutines.launch 62 import kotlinx.coroutines.launch
65 -import kotlinx.coroutines.withContext  
66 63
67 @OptIn(ExperimentalMaterial3Api::class) 64 @OptIn(ExperimentalMaterial3Api::class)
68 @Composable 65 @Composable
69 internal fun ChatTab(repo: Repository) { 66 internal fun ChatTab(repo: Repository) {
70 val ctx = LocalContext.current 67 val ctx = LocalContext.current
71 val scope = rememberCoroutineScope() 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 if (cid == null) kotlinx.coroutines.flow.flowOf(emptyList()) 80 if (cid == null) kotlinx.coroutines.flow.flowOf(emptyList())
88 - else repo.chats.observeMessages(cid) 81 + else vm.repo.chats.observeMessages(cid)
89 }.collectAsState(initial = emptyList()) 82 }.collectAsState(initial = emptyList())
90 83
91 - // 流式中的 assistant 回复(还没落库)。  
92 - var streaming by remember { mutableStateOf(false) }  
93 - var streamingText by remember { mutableStateOf("") }  
94 -  
95 var showSettings by remember { mutableStateOf(false) } 84 var showSettings by remember { mutableStateOf(false) }
96 - var input by remember { mutableStateOf("") }  
97 85
98 val drawerState = rememberDrawerState(DrawerValue.Closed) 86 val drawerState = rememberDrawerState(DrawerValue.Closed)
99 val listState = rememberLazyListState() 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 if (total > 0) listState.animateScrollToItem(total - 1) 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 ModalNavigationDrawer( 95 ModalNavigationDrawer(
171 drawerState = drawerState, 96 drawerState = drawerState,
172 drawerContent = { 97 drawerContent = {
@@ -175,8 +100,7 @@ internal fun ChatTab(repo: Repository) { @@ -175,8 +100,7 @@ internal fun ChatTab(repo: Repository) {
175 Text("对话历史", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp)) 100 Text("对话历史", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
176 FilledTonalButton( 101 FilledTonalButton(
177 onClick = { 102 onClick = {
178 - currentConvId = null  
179 - streamingText = "" 103 + vm.newConversation()
180 scope.launch { drawerState.close() } 104 scope.launch { drawerState.close() }
181 }, 105 },
182 modifier = Modifier.fillMaxWidth(), 106 modifier = Modifier.fillMaxWidth(),
@@ -197,23 +121,15 @@ internal fun ChatTab(repo: Repository) { @@ -197,23 +121,15 @@ internal fun ChatTab(repo: Repository) {
197 overflow = TextOverflow.Ellipsis, 121 overflow = TextOverflow.Ellipsis,
198 ) 122 )
199 }, 123 },
200 - selected = conv.id == currentConvId, 124 + selected = conv.id == vm.currentConvId,
201 onClick = { 125 onClick = {
202 - currentConvId = conv.id  
203 - selectedTargetId = conv.targetId  
204 - streamingText = "" 126 + vm.openConversation(conv.id, conv.targetId)
205 scope.launch { drawerState.close() } 127 scope.launch { drawerState.close() }
206 }, 128 },
207 badge = { 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,13 +142,7 @@ internal fun ChatTab(repo: Repository) {
226 // 顶栏:菜单 + 聊天对象选择 + 设置。 142 // 顶栏:菜单 + 聊天对象选择 + 设置。
227 TopAppBar( 143 TopAppBar(
228 title = { TargetSelector(targets = targets, selected = selectedTarget) { picked -> 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 navigationIcon = { 147 navigationIcon = {
238 IconButton(onClick = { scope.launch { drawerState.open() } }) { 148 IconButton(onClick = { scope.launch { drawerState.open() } }) {
@@ -240,7 +150,7 @@ internal fun ChatTab(repo: Repository) { @@ -240,7 +150,7 @@ internal fun ChatTab(repo: Repository) {
240 } 150 }
241 }, 151 },
242 actions = { 152 actions = {
243 - IconButton(onClick = { currentConvId = null; streamingText = "" }) { 153 + IconButton(onClick = { vm.newConversation() }) {
244 Icon(Icons.Filled.Add, contentDescription = "新对话") 154 Icon(Icons.Filled.Add, contentDescription = "新对话")
245 } 155 }
246 IconButton(onClick = { showSettings = true }) { 156 IconButton(onClick = { showSettings = true }) {
@@ -251,7 +161,7 @@ internal fun ChatTab(repo: Repository) { @@ -251,7 +161,7 @@ internal fun ChatTab(repo: Repository) {
251 161
252 // 消息列表。 162 // 消息列表。
253 Box(modifier = Modifier.weight(1f).fillMaxWidth()) { 163 Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
254 - if (messages.isEmpty() && !streaming) { 164 + if (messages.isEmpty() && !vm.streaming) {
255 Column( 165 Column(
256 modifier = Modifier.fillMaxSize().padding(24.dp), 166 modifier = Modifier.fillMaxSize().padding(24.dp),
257 verticalArrangement = Arrangement.Center, 167 verticalArrangement = Arrangement.Center,
@@ -273,15 +183,32 @@ internal fun ChatTab(repo: Repository) { @@ -273,15 +183,32 @@ internal fun ChatTab(repo: Repository) {
273 verticalArrangement = Arrangement.spacedBy(8.dp), 183 verticalArrangement = Arrangement.spacedBy(8.dp),
274 ) { 184 ) {
275 items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) } 185 items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) }
276 - if (streaming) { 186 + if (vm.streaming) {
277 item(key = "streaming") { 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 HorizontalDivider() 212 HorizontalDivider()
286 213
287 // 输入区。 214 // 输入区。
@@ -291,14 +218,14 @@ internal fun ChatTab(repo: Repository) { @@ -291,14 +218,14 @@ internal fun ChatTab(repo: Repository) {
291 horizontalArrangement = Arrangement.spacedBy(8.dp), 218 horizontalArrangement = Arrangement.spacedBy(8.dp),
292 ) { 219 ) {
293 OutlinedTextField( 220 OutlinedTextField(
294 - value = input,  
295 - onValueChange = { input = it }, 221 + value = vm.input,
  222 + onValueChange = { vm.input = it },
296 modifier = Modifier.weight(1f), 223 modifier = Modifier.weight(1f),
297 placeholder = { Text("发消息给 ${selectedTarget.label}…") }, 224 placeholder = { Text("发消息给 ${selectedTarget.label}…") },
298 maxLines = 5, 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 CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp) 229 CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp)
303 } else { 230 } else {
304 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") 231 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
@@ -316,7 +243,7 @@ internal fun ChatTab(repo: Repository) { @@ -316,7 +243,7 @@ internal fun ChatTab(repo: Repository) {
316 onDismiss = { showSettings = false }, 243 onDismiss = { showSettings = false },
317 onSave = { base, m2, m4 -> 244 onSave = { base, m2, m4 ->
318 ChatConfig.save(ctx, base, m2, m4) 245 ChatConfig.save(ctx, base, m2, m4)
319 - configRev++ 246 + vm.bumpConfig()
320 showSettings = false 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,6 +101,43 @@ interface SegmentDao {
101 ) 101 )
102 fun observeTimeline(limit: Int = 100): Flow<List<SegmentRow>> 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 /** Full-text search via FTS4 virtual table. Falls back to LIKE if MATCH query is empty. */ 141 /** Full-text search via FTS4 virtual table. Falls back to LIKE if MATCH query is empty. */
105 @Query( 142 @Query(
106 """ 143 """
@@ -144,6 +181,10 @@ interface SummaryDao { @@ -144,6 +181,10 @@ interface SummaryDao {
144 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n") 181 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n")
145 fun recent(n: Int): Flow<List<Summary>> 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 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC") 188 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC")
148 suspend fun all(): List<Summary> 189 suspend fun all(): List<Summary>
149 190