diff --git a/lifelog-recorder/app/src/main/AndroidManifest.xml b/lifelog-recorder/app/src/main/AndroidManifest.xml index e8c1a7c..a9f41d1 100644 --- a/lifelog-recorder/app/src/main/AndroidManifest.xml +++ b/lifelog-recorder/app/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ android:name=".MainActivity" android:exported="true" android:label="Lifelog" + android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.Lifelog"> diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt index 5a9cf6c..6be7099 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,119 +55,43 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.lemon.lifelog.chat.ChatClient import com.lemon.lifelog.chat.ChatConfig import com.lemon.lifelog.chat.ChatTarget -import com.lemon.lifelog.data.ChatConversation -import com.lemon.lifelog.data.ChatMessage +import com.lemon.lifelog.chat.ChatViewModel import com.lemon.lifelog.data.Repository -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ChatTab(repo: Repository) { val ctx = LocalContext.current val scope = rememberCoroutineScope() - val client = remember { ChatClient() } + // 会话状态都在 ViewModel 里(随 Activity 存活),切 tab 回来不丢、流式不被取消。 + val vm: ChatViewModel = androidx.lifecycle.viewmodel.compose.viewModel() - // 配置刷新计数器:改完端点设置后 bump 一下重新读 targets。 - var configRev by remember { mutableStateOf(0) } - val targets = remember(configRev) { ChatConfig.targets(ctx) } + val targets = remember(vm.configRev) { ChatConfig.targets(ctx) } + val selectedTarget = remember(vm.selectedTargetId, vm.configRev) { ChatConfig.targetById(ctx, vm.selectedTargetId) } - val conversations by repo.chats.observeConversations().collectAsState(initial = emptyList()) - - var currentConvId by remember { mutableStateOf(null) } - var selectedTargetId by remember { mutableStateOf(targets.first().id) } - val selectedTarget = remember(selectedTargetId, configRev) { ChatConfig.targetById(ctx, selectedTargetId) } + val conversations by vm.repo.chats.observeConversations().collectAsState(initial = emptyList()) // 当前会话的消息流。 - val messages by remember(currentConvId) { - val cid = currentConvId + val messages by remember(vm.currentConvId) { + val cid = vm.currentConvId if (cid == null) kotlinx.coroutines.flow.flowOf(emptyList()) - else repo.chats.observeMessages(cid) + else vm.repo.chats.observeMessages(cid) }.collectAsState(initial = emptyList()) - // 流式中的 assistant 回复(还没落库)。 - var streaming by remember { mutableStateOf(false) } - var streamingText by remember { mutableStateOf("") } - var showSettings by remember { mutableStateOf(false) } - var input by remember { mutableStateOf("") } val drawerState = rememberDrawerState(DrawerValue.Closed) val listState = rememberLazyListState() // 新消息 / 流式更新时滚到底。 - androidx.compose.runtime.LaunchedEffect(messages.size, streamingText) { - val total = messages.size + if (streaming) 1 else 0 + androidx.compose.runtime.LaunchedEffect(messages.size, vm.streamingText) { + val total = messages.size + if (vm.streaming) 1 else 0 if (total > 0) listState.animateScrollToItem(total - 1) } - fun send() { - val text = input.trim() - if (text.isEmpty() || streaming) return - input = "" - val target = selectedTarget - scope.launch { - val now = System.currentTimeMillis() - var convId = currentConvId - if (convId == null) { - convId = withContext(Dispatchers.IO) { - repo.chats.insertConversation( - ChatConversation( - title = text.take(20), - targetId = target.id, - createdAt = now, - updatedAt = now, - ), - ) - } - currentConvId = convId - } - withContext(Dispatchers.IO) { - repo.chats.insertMessage(ChatMessage(conversationId = convId!!, role = "user", content = text, createdAt = now)) - repo.chats.touch(convId!!, now) - } - val history = withContext(Dispatchers.IO) { repo.chats.messagesOf(convId!!) } - .map { it.role to it.content } - - streaming = true - streamingText = "" - try { - val full = client.stream(target, history) { delta -> streamingText += delta } - withContext(Dispatchers.IO) { - repo.chats.insertMessage( - ChatMessage( - conversationId = convId!!, - role = "assistant", - content = full.ifBlank { "(空回复)" }, - model = target.model, - createdAt = System.currentTimeMillis(), - ), - ) - repo.chats.touch(convId!!, System.currentTimeMillis()) - } - } catch (t: Throwable) { - withContext(Dispatchers.IO) { - repo.chats.insertMessage( - ChatMessage( - conversationId = convId!!, - role = "assistant", - content = "⚠ 出错:${t.message ?: t.toString()}", - model = target.model, - createdAt = System.currentTimeMillis(), - ), - ) - } - } finally { - streaming = false - streamingText = "" - } - } - } - ModalNavigationDrawer( drawerState = drawerState, drawerContent = { @@ -175,8 +100,7 @@ internal fun ChatTab(repo: Repository) { Text("对话历史", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp)) FilledTonalButton( onClick = { - currentConvId = null - streamingText = "" + vm.newConversation() scope.launch { drawerState.close() } }, modifier = Modifier.fillMaxWidth(), @@ -197,23 +121,15 @@ internal fun ChatTab(repo: Repository) { overflow = TextOverflow.Ellipsis, ) }, - selected = conv.id == currentConvId, + selected = conv.id == vm.currentConvId, onClick = { - currentConvId = conv.id - selectedTargetId = conv.targetId - streamingText = "" + vm.openConversation(conv.id, conv.targetId) scope.launch { drawerState.close() } }, badge = { - IconButton(onClick = { - scope.launch { - withContext(Dispatchers.IO) { - repo.chats.deleteMessages(conv.id) - repo.chats.deleteConversation(conv.id) - } - if (currentConvId == conv.id) currentConvId = null - } - }) { Icon(Icons.Filled.Delete, contentDescription = "删除") } + IconButton(onClick = { vm.deleteConversation(conv.id) }) { + Icon(Icons.Filled.Delete, contentDescription = "删除") + } }, ) } @@ -226,13 +142,7 @@ internal fun ChatTab(repo: Repository) { // 顶栏:菜单 + 聊天对象选择 + 设置。 TopAppBar( title = { TargetSelector(targets = targets, selected = selectedTarget) { picked -> - selectedTargetId = picked.id - val cid = currentConvId - if (cid != null) { - scope.launch { - withContext(Dispatchers.IO) { repo.chats.updateTarget(cid, picked.id, System.currentTimeMillis()) } - } - } + vm.selectTarget(picked.id) } }, navigationIcon = { IconButton(onClick = { scope.launch { drawerState.open() } }) { @@ -240,7 +150,7 @@ internal fun ChatTab(repo: Repository) { } }, actions = { - IconButton(onClick = { currentConvId = null; streamingText = "" }) { + IconButton(onClick = { vm.newConversation() }) { Icon(Icons.Filled.Add, contentDescription = "新对话") } IconButton(onClick = { showSettings = true }) { @@ -251,7 +161,7 @@ internal fun ChatTab(repo: Repository) { // 消息列表。 Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - if (messages.isEmpty() && !streaming) { + if (messages.isEmpty() && !vm.streaming) { Column( modifier = Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center, @@ -273,15 +183,32 @@ internal fun ChatTab(repo: Repository) { verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) } - if (streaming) { + if (vm.streaming) { item(key = "streaming") { - MessageBubble("assistant", streamingText.ifEmpty { "…" }, inProgress = true) + MessageBubble("assistant", vm.streamingText.ifEmpty { "…" }, inProgress = true) } } } } } + // lifelog 模式开关。 + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = vm.lifelogMode, + onClick = { vm.setLifelog(!vm.lifelogMode) }, + label = { Text("🧠 lifelog 模式") }, + ) + Text( + if (vm.lifelogMode) "回答会带上你的纪要/转写检索" else "纯聊天,不读 lifelog", + style = MaterialTheme.typography.labelSmall, + ) + } + HorizontalDivider() // 输入区。 @@ -291,14 +218,14 @@ internal fun ChatTab(repo: Repository) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { OutlinedTextField( - value = input, - onValueChange = { input = it }, + value = vm.input, + onValueChange = { vm.input = it }, modifier = Modifier.weight(1f), placeholder = { Text("发消息给 ${selectedTarget.label}…") }, maxLines = 5, ) - IconButton(onClick = { send() }, enabled = input.isNotBlank() && !streaming) { - if (streaming) { + IconButton(onClick = { vm.send() }, enabled = vm.input.isNotBlank() && !vm.streaming) { + if (vm.streaming) { CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp) } else { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") @@ -316,7 +243,7 @@ internal fun ChatTab(repo: Repository) { onDismiss = { showSettings = false }, onSave = { base, m2, m4 -> ChatConfig.save(ctx, base, m2, m4) - configRev++ + vm.bumpConfig() showSettings = false }, ) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt new file mode 100644 index 0000000..d690a02 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt @@ -0,0 +1,155 @@ +package com.lemon.lifelog.chat + +import android.app.Application +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.lemon.lifelog.data.ChatConversation +import com.lemon.lifelog.data.ChatMessage +import com.lemon.lifelog.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * 聊天会话状态 + 发送逻辑的宿主。放在 ViewModel 里(随 Activity 存活)而不是 ChatTab + * composable 里,是为了:切到别的 tab 再回来时—— + * 1. 正在跑的流式请求不被取消(viewModelScope,不随 ChatTab 离开 composition 而 cancel); + * 2. 当前会话 / 选中模型 / lifelog 开关不丢(且持久化到 prefs,连重启都记住)。 + */ +class ChatViewModel(app: Application) : AndroidViewModel(app) { + val repo = Repository(app) + private val client = ChatClient() + private val prefs = app.getSharedPreferences(ChatConfig.PREFS, Context.MODE_PRIVATE) + + var currentConvId by mutableStateOf(prefs.getLong(K_CONV, -1L).takeIf { it > 0 }) + private set + var selectedTargetId by mutableStateOf( + prefs.getString(K_TARGET, null) ?: ChatConfig.targets(app).first().id, + ) + private set + var lifelogMode by mutableStateOf(prefs.getBoolean(K_LIFELOG, false)) + private set + + var input by mutableStateOf("") + var streaming by mutableStateOf(false) + private set + var streamingText by mutableStateOf("") + private set + var configRev by mutableStateOf(0) + private set + + fun setLifelog(on: Boolean) { + lifelogMode = on + prefs.edit().putBoolean(K_LIFELOG, on).apply() + } + + fun selectTarget(id: String) { + selectedTargetId = id + prefs.edit().putString(K_TARGET, id).apply() + val cid = currentConvId + if (cid != null) viewModelScope.launch { + withContext(Dispatchers.IO) { repo.chats.updateTarget(cid, id, System.currentTimeMillis()) } + } + } + + fun openConversation(id: Long, targetId: String) { + currentConvId = id + prefs.edit().putLong(K_CONV, id).apply() + selectedTargetId = targetId + prefs.edit().putString(K_TARGET, targetId).apply() + streamingText = "" + } + + fun newConversation() { + currentConvId = null + prefs.edit().remove(K_CONV).apply() + streamingText = "" + } + + fun bumpConfig() { + configRev++ + } + + fun deleteConversation(id: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + repo.chats.deleteMessages(id) + repo.chats.deleteConversation(id) + } + if (currentConvId == id) newConversation() + } + } + + fun send() { + val text = input.trim() + if (text.isEmpty() || streaming) return + input = "" + val app = getApplication() + val target = ChatConfig.targetById(app, selectedTargetId) + val useLifelog = lifelogMode + viewModelScope.launch { + val now = System.currentTimeMillis() + var convId = currentConvId + if (convId == null) { + convId = withContext(Dispatchers.IO) { + repo.chats.insertConversation( + ChatConversation(title = text.take(20), targetId = target.id, createdAt = now, updatedAt = now), + ) + } + currentConvId = convId + prefs.edit().putLong(K_CONV, convId!!).apply() + } + val cid = convId!! + withContext(Dispatchers.IO) { + repo.chats.insertMessage(ChatMessage(conversationId = cid, role = "user", content = text, createdAt = now)) + repo.chats.touch(cid, now) + } + val history = withContext(Dispatchers.IO) { repo.chats.messagesOf(cid) }.map { it.role to it.content } + + streaming = true + streamingText = "" + try { + val messagesToSend = if (useLifelog) { + val c = LifelogRetriever.buildContext(repo, text) + if (c != null) listOf("system" to c) + history else history + } else { + history + } + val full = client.stream(target, messagesToSend) { delta -> streamingText += delta } + withContext(Dispatchers.IO) { + repo.chats.insertMessage( + ChatMessage( + conversationId = cid, role = "assistant", + content = full.ifBlank { "(空回复)" }, model = target.model, + createdAt = System.currentTimeMillis(), + ), + ) + repo.chats.touch(cid, System.currentTimeMillis()) + } + } catch (t: Throwable) { + withContext(Dispatchers.IO) { + repo.chats.insertMessage( + ChatMessage( + conversationId = cid, role = "assistant", + content = "⚠ 出错:${t.message ?: t.toString()}", model = target.model, + createdAt = System.currentTimeMillis(), + ), + ) + } + } finally { + streaming = false + streamingText = "" + } + } + } + + companion object { + private const val K_LIFELOG = "lifelog_mode" + private const val K_TARGET = "selected_target" + private const val K_CONV = "current_conv" + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogRetriever.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogRetriever.kt new file mode 100644 index 0000000..3c7ee9c --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogRetriever.kt @@ -0,0 +1,137 @@ +package com.lemon.lifelog.chat + +import android.util.Log +import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.data.SegmentRow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 「lifelog 模式」的本地检索(RAG)。聊天时把跟用户问题相关的 lifelog 内容 + * 拼成一段 system context 注入到模型请求里——本地 2B/4B 和 Hermes 通用。 + * + * 检索三路(高信号优先): + * 1. 会话纪要(已 LLM 压缩,覆盖面广、最省 token)——backbone。 + * 2. 关键词子串命中的转写片段(LIKE,对中文可靠)——精确细节。 + * 3. 最近时间轴片段——兜底「最近/今天」类问题。 + */ +object LifelogRetriever { + private val FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) + private val TS_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) + + /** 注入 context 的字符上限(≈3–4k 中文 token,兼顾本地 4B 的上下文窗口)。 */ + private const val MAX_CHARS = 6000 + private const val SUMMARY_LIMIT = 20 + private const val SUMMARY_EACH_CHARS = 400 + private const val HITS_PER_TERM = 12 + private const val HITS_TOTAL = 30 + private const val RECENT_LIMIT = 20 + private const val MAX_TERMS = 16 + + // 纯虚词/高频功能 bigram:命中无意义、只会污染候选,检索时丢掉。 + private val STOP_BIGRAMS = setOf( + "什么", "怎么", "这个", "那个", "一下", "这样", "那样", "可以", "就是", "然后", + "因为", "所以", "但是", "如果", "的话", "我们", "你们", "他们", "今天", "昨天", + "最近", "这周", "本周", "上周", "有没", "没有", "知道", "觉得", "可能", + ) + + /** 给定用户问题,返回要注入的 system context;无任何数据返回 null。 */ + suspend fun buildContext(repo: Repository, question: String): String? = withContext(Dispatchers.IO) { + val summaries = repo.summaries.recentList(SUMMARY_LIMIT) + val terms = searchTerms(question) + + // bigram 召回 + 重叠打分:一段转写命中越多 query bigram 越相关, + // 单 bigram 噪声(如某段只含"内容")被排到尾部丢掉。 + val cand = LinkedHashMap() + for (t in terms) { + repo.segments.searchTextLike(t, HITS_PER_TERM).forEach { row -> + if (row.text != null) cand.putIfAbsent(row.id, row) + } + } + val scored = cand.values + .map { row -> row to terms.count { row.text!!.contains(it) } } + .filter { it.second > 0 } + // 有 ≥2 命中的强匹配时,丢掉只命中 1 个 bigram 的弱噪声。 + val minScore = if (scored.any { it.second >= 2 }) 2 else 1 + val hits = scored + .filter { it.second >= minScore } + .sortedWith(compareByDescending> { it.second }.thenByDescending { it.first.startTs }) + .take(HITS_TOTAL) + .map { it.first } + val recent = repo.segments.timelineList(RECENT_LIMIT).filter { it.text != null } + + if (summaries.isEmpty() && hits.isEmpty() && recent.isEmpty()) return@withContext null + + val sb = StringBuilder() + sb.appendLine( + "你是用户的私人 lifelog 助手。lifelog 是用户 24/7 录音转写出来的个人记忆库。" + + "下面是从中检索出的相关内容,请**只依据这些内容**回答用户的问题;" + + "若不足以回答,明确说「我的 lifelog 里没找到相关内容」,不要编造。" + + "引用具体事实时可带上 [时间戳]。", + ) + sb.appendLine() + + if (summaries.isNotEmpty()) { + sb.appendLine("【会话纪要】(共 ${summaries.size} 篇,时间倒序)") + summaries.forEach { s -> + val range = "${FMT.format(Date(s.windowStartTs))}–${FMT.format(Date(s.windowEndTs))}" + val body = s.markdown.trim().replace("\n", " ").take(SUMMARY_EACH_CHARS) + sb.appendLine("- [$range] $body") + } + sb.appendLine() + } + + if (hits.isNotEmpty()) { + sb.appendLine("【相关转写片段】(按问题关键词命中,相关度优先)") + hits.forEach { sb.appendLine(formatSeg(it)) } + sb.appendLine() + } + + if (recent.isNotEmpty()) { + sb.appendLine("【最近转写】(最近 ${recent.size} 段,供「最近/今天」类问题参考)") + recent.forEach { sb.appendLine(formatSeg(it)) } + } + + var ctx = sb.toString() + if (ctx.length > MAX_CHARS) ctx = ctx.take(MAX_CHARS) + "\n…(上下文已截断)" + Log.i( + "LifelogRAG", + "q=\"${question.take(40)}\" terms=$terms " + + "纪要=${summaries.size} 命中=${hits.size} 最近=${recent.size} chars=${ctx.length}", + ) + ctx + } + + private fun formatSeg(r: SegmentRow): String { + val who = r.speakerName ?: "未知" + return "[${TS_FMT.format(Date(r.startTs))}] $who:${r.text?.trim().orEmpty()}" + } + + /** + * 把问题切成检索词:英文/数字整词保留;中文按**重叠 bigram**切。 + * 当前 FTS4 unicode61 对中文把整段 CJK 当一个 token、短语检索不命中; + * bigram + LIKE 才能让"黑神话悟空"这种被真正召回(黑神/神话/悟空…多 bigram 命中)。 + */ + private fun searchTerms(question: String): List { + val cleaned = question.lowercase() + .replace(Regex("[\\p{Punct},。!?、;:“”‘’()《》【】…—~·\\s]+"), " ") + val terms = LinkedHashSet() + for (tok in cleaned.split(" ")) { + if (tok.isBlank()) continue + if (tok.all { it.code < 128 }) { + if (tok.length >= 2) terms += tok // 英文/数字整词 + } else { + val s = tok.filter { !it.isWhitespace() } + for (i in 0 until s.length - 1) { + val bg = s.substring(i, i + 2) // CJK 重叠 bigram + if (bg !in STOP_BIGRAMS) terms += bg + } + } + if (terms.size >= MAX_TERMS) break + } + return terms.take(MAX_TERMS).toList() + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 84013ca..35e9b40 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -101,6 +101,43 @@ interface SegmentDao { ) fun observeTimeline(limit: Int = 100): Flow> + /** 一次性取最近时间轴(RAG 检索用,非 Flow)。 */ + @Query( + """ + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + t.text AS text, s.audioPath AS audioPath, + s.speakerId AS speakerId, s.speakerSim AS speakerSim, + sp.name AS speakerName + FROM segments s + LEFT JOIN transcripts t ON t.segmentId = s.id + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + ORDER BY s.startTs DESC + LIMIT :limit + """, + ) + suspend fun timelineList(limit: Int): List + + /** + * 中文子串检索(RAG 用):LIKE '%kw%'。 + * 当前 FTS4 是 unicode61,对中文把整段 CJK 当一个 token、短语检索几乎不命中; + * 个人库行数少,直接 LIKE 子串扫最稳。 + */ + @Query( + """ + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + t.text AS text, s.audioPath AS audioPath, + s.speakerId AS speakerId, s.speakerSim AS speakerSim, + sp.name AS speakerName + FROM segments s + INNER JOIN transcripts t ON t.segmentId = s.id + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + WHERE t.text LIKE '%' || :kw || '%' + ORDER BY s.startTs DESC + LIMIT :limit + """, + ) + suspend fun searchTextLike(kw: String, limit: Int): List + /** Full-text search via FTS4 virtual table. Falls back to LIKE if MATCH query is empty. */ @Query( """ @@ -144,6 +181,10 @@ interface SummaryDao { @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n") fun recent(n: Int): Flow> + /** 一次性取最近 n 篇纪要(RAG 检索用,非 Flow)。 */ + @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n") + suspend fun recentList(n: Int): List + @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC") suspend fun all(): List