Commit 8f47214f811c931bada0a56ce9240980eccbc4bc

Authored by yibo
1 parent e3578daa

feat(crm/profile): 结构化档案 JSON + 性格/社媒 UI + ReAct 工具循环

Person.profileJson(DB v21)存 MBTI/八字/性格/社媒账号 JSON blob;
PersonProfileScreen 简历 tab 新增性格分析卡 + 社媒账号卡(可点击外链)
+ 「✎ 编辑性格/社媒」入口;EditProfileJsonDialog 13 字段滚动编辑;
AI 工具 update_person_profile(字段粒度写入)+ get_person 输出档案字段;
ChatViewModel 工具循环改 ReAct 文本协议(TOOL_CALL: {json})兼容所有网关。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
@@ -58,7 +58,9 @@ import android.widget.Toast @@ -58,7 +58,9 @@ import android.widget.Toast
58 import androidx.compose.ui.draw.clip 58 import androidx.compose.ui.draw.clip
59 import androidx.compose.ui.graphics.Brush 59 import androidx.compose.ui.graphics.Brush
60 import androidx.compose.ui.platform.LocalContext 60 import androidx.compose.ui.platform.LocalContext
  61 +import androidx.compose.ui.platform.LocalUriHandler
61 import androidx.compose.ui.layout.ContentScale 62 import androidx.compose.ui.layout.ContentScale
  63 +import org.json.JSONObject
62 import androidx.compose.ui.text.style.TextOverflow 64 import androidx.compose.ui.text.style.TextOverflow
63 import androidx.compose.ui.unit.dp 65 import androidx.compose.ui.unit.dp
64 import coil.compose.AsyncImage 66 import coil.compose.AsyncImage
@@ -112,6 +114,7 @@ internal fun PersonProfileScreen( @@ -112,6 +114,7 @@ internal fun PersonProfileScreen(
112 var bindVoice by remember { mutableStateOf(false) } 114 var bindVoice by remember { mutableStateOf(false) }
113 var recordVoice by remember { mutableStateOf(false) } 115 var recordVoice by remember { mutableStateOf(false) }
114 var editProfile by remember { mutableStateOf(false) } 116 var editProfile by remember { mutableStateOf(false) }
  117 + var editProfileJson by remember { mutableStateOf(false) }
115 val player = remember { SegmentPlayer() } 118 val player = remember { SegmentPlayer() }
116 var playingId by remember { mutableStateOf<Long?>(null) } 119 var playingId by remember { mutableStateOf<Long?>(null) }
117 DisposableEffect(player) { onDispose { player.stop() } } 120 DisposableEffect(player) { onDispose { player.stop() } }
@@ -177,7 +180,7 @@ internal fun PersonProfileScreen( @@ -177,7 +180,7 @@ internal fun PersonProfileScreen(
177 } 180 }
178 } 181 }
179 when (tab) { 182 when (tab) {
180 - 0 -> item(key = "resume") { person?.let { p -> ResumeTab(p, onEdit = { editProfile = true }, onEnrich = { vm.enrichImpression(personId) }) } } 183 + 0 -> item(key = "resume") { person?.let { p -> ResumeTab(p, onEdit = { editProfile = true }, onEditProfile = { editProfileJson = true }, onEnrich = { vm.enrichImpression(personId) }) } }
181 1 -> if (fPhotos.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") } 184 1 -> if (fPhotos.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") }
182 else items(fPhotos.chunked(3), key = { it.first().id }) { rowPhotos -> 185 else items(fPhotos.chunked(3), key = { it.first().id }) { rowPhotos ->
183 Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { 186 Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -250,6 +253,16 @@ internal fun PersonProfileScreen( @@ -250,6 +253,16 @@ internal fun PersonProfileScreen(
250 } 253 }
251 } 254 }
252 255
  256 + if (editProfileJson) {
  257 + person?.let { p ->
  258 + EditProfileJsonDialog(
  259 + p = p,
  260 + onDismiss = { editProfileJson = false },
  261 + onSave = { json -> vm.updatePersonProfileJson(personId, json); editProfileJson = false },
  262 + )
  263 + }
  264 + }
  265 +
253 } 266 }
254 267
255 /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ 268 /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */
@@ -399,9 +412,13 @@ private fun ProfileHero( @@ -399,9 +412,13 @@ private fun ProfileHero(
399 } 412 }
400 } 413 }
401 414
402 -/** 「简历」tab:真名 + 简历正文 + AI 印象(只读展示;编辑/生成走按钮)。外层 LazyColumn 负责滚动。 */ 415 +/** 「简历」tab:真名 + 简历正文 + 性格分析 + 社媒账号 + AI 印象(只读展示;编辑走按钮)。 */
403 @Composable 416 @Composable
404 -private fun ResumeTab(p: Person, onEdit: () -> Unit, onEnrich: () -> Unit) { 417 +private fun ResumeTab(p: Person, onEdit: () -> Unit, onEditProfile: () -> Unit, onEnrich: () -> Unit) {
  418 + val uriHandler = LocalUriHandler.current
  419 + val profile = remember(p.profileJson) {
  420 + p.profileJson?.takeIf { it.isNotBlank() }?.let { runCatching { JSONObject(it) }.getOrNull() }
  421 + }
405 Column( 422 Column(
406 modifier = Modifier.fillMaxWidth().padding(16.dp), 423 modifier = Modifier.fillMaxWidth().padding(16.dp),
407 verticalArrangement = Arrangement.spacedBy(10.dp), 424 verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -425,6 +442,68 @@ private fun ResumeTab(p: Person, onEdit: () -&gt; Unit, onEnrich: () -&gt; Unit) { @@ -425,6 +442,68 @@ private fun ResumeTab(p: Person, onEdit: () -&gt; Unit, onEnrich: () -&gt; Unit) {
425 } 442 }
426 } 443 }
427 } 444 }
  445 + // 性格分析卡
  446 + val mbti = profile?.optString("mbti").orEmpty()
  447 + val bazi = profile?.optString("bazi").orEmpty()
  448 + val personality = profile?.optString("personality").orEmpty()
  449 + if (mbti.isNotBlank() || bazi.isNotBlank() || personality.isNotBlank()) {
  450 + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) {
  451 + Column(Modifier.padding(12.dp)) {
  452 + Text("🔮 性格分析", style = MaterialTheme.typography.titleSmall)
  453 + Spacer(Modifier.height(6.dp))
  454 + if (mbti.isNotBlank()) {
  455 + Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(50)) {
  456 + Text("MBTI: $mbti", style = MaterialTheme.typography.labelMedium,
  457 + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
  458 + }
  459 + Spacer(Modifier.height(4.dp))
  460 + }
  461 + if (bazi.isNotBlank()) Text("八字:$bazi", style = MaterialTheme.typography.bodySmall)
  462 + if (personality.isNotBlank()) { Spacer(Modifier.height(4.dp)); Text(personality, style = MaterialTheme.typography.bodyMedium) }
  463 + }
  464 + }
  465 + }
  466 + // 社媒账号卡
  467 + val socialKeys = listOf(
  468 + "blog" to "📝 Blog", "github" to "💻 GitHub", "linkedin" to "💼 LinkedIn",
  469 + "x" to "🐦 X", "youtube" to "▶ YouTube", "xiaohongshu" to "📕 小红书",
  470 + "douyin" to "🎵 抖音", "weibo" to "🐤 微博", "website" to "🌐 网站",
  471 + )
  472 + val socials = socialKeys.mapNotNull { (key, label) ->
  473 + profile?.optString(key)?.takeIf { it.isNotBlank() }?.let { Triple(label, key, it) }
  474 + }
  475 + if (socials.isNotEmpty()) {
  476 + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) {
  477 + Column(Modifier.padding(12.dp)) {
  478 + Text("🔗 社媒账号", style = MaterialTheme.typography.titleSmall)
  479 + Spacer(Modifier.height(6.dp))
  480 + socials.forEach { (label, _, value) ->
  481 + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
  482 + Text(label, style = MaterialTheme.typography.labelSmall,
  483 + modifier = Modifier.widthIn(min = 88.dp), maxLines = 1)
  484 + if (value.startsWith("http")) {
  485 + TextButton(
  486 + onClick = { runCatching { uriHandler.openUri(value) } },
  487 + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
  488 + ) {
  489 + Text(value, style = MaterialTheme.typography.bodySmall,
  490 + maxLines = 1, overflow = TextOverflow.Ellipsis,
  491 + modifier = Modifier.widthIn(max = 240.dp))
  492 + }
  493 + } else {
  494 + Text(value, style = MaterialTheme.typography.bodySmall,
  495 + maxLines = 1, overflow = TextOverflow.Ellipsis,
  496 + modifier = Modifier.weight(1f))
  497 + }
  498 + }
  499 + }
  500 + }
  501 + }
  502 + }
  503 + // 编辑性格/社媒按钮(始终显示)
  504 + TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) {
  505 + Text("✎ 编辑性格/社媒", style = MaterialTheme.typography.labelMedium)
  506 + }
428 Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { 507 Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) {
429 Column(Modifier.padding(12.dp)) { 508 Column(Modifier.padding(12.dp)) {
430 Text("🧠 AI 印象", style = MaterialTheme.typography.titleSmall) 509 Text("🧠 AI 印象", style = MaterialTheme.typography.titleSmall)
@@ -457,6 +536,89 @@ private fun Pill(text: String, highlight: Boolean = false) { @@ -457,6 +536,89 @@ private fun Pill(text: String, highlight: Boolean = false) {
457 } 536 }
458 } 537 }
459 538
  539 +/** 编辑结构化档案:MBTI / 八字 / 性格描述 / 社媒账号。 */
  540 +@Composable
  541 +private fun EditProfileJsonDialog(p: Person, onDismiss: () -> Unit, onSave: (String?) -> Unit) {
  542 + val existing = remember(p.profileJson) {
  543 + p.profileJson?.takeIf { it.isNotBlank() }?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
  544 + }
  545 + var mbti by remember { mutableStateOf(existing.optString("mbti")) }
  546 + var bazi by remember { mutableStateOf(existing.optString("bazi")) }
  547 + var personality by remember { mutableStateOf(existing.optString("personality")) }
  548 + var blog by remember { mutableStateOf(existing.optString("blog")) }
  549 + var github by remember { mutableStateOf(existing.optString("github")) }
  550 + var linkedin by remember { mutableStateOf(existing.optString("linkedin")) }
  551 + var xHandle by remember { mutableStateOf(existing.optString("x")) }
  552 + var youtube by remember { mutableStateOf(existing.optString("youtube")) }
  553 + var xiaohongshu by remember { mutableStateOf(existing.optString("xiaohongshu")) }
  554 + var douyin by remember { mutableStateOf(existing.optString("douyin")) }
  555 + var weibo by remember { mutableStateOf(existing.optString("weibo")) }
  556 + var website by remember { mutableStateOf(existing.optString("website")) }
  557 + var notes by remember { mutableStateOf(existing.optString("notes")) }
  558 +
  559 + fun buildJson(): String? {
  560 + val obj = JSONObject()
  561 + if (mbti.isNotBlank()) obj.put("mbti", mbti.trim())
  562 + if (bazi.isNotBlank()) obj.put("bazi", bazi.trim())
  563 + if (personality.isNotBlank()) obj.put("personality", personality.trim())
  564 + if (blog.isNotBlank()) obj.put("blog", blog.trim())
  565 + if (github.isNotBlank()) obj.put("github", github.trim())
  566 + if (linkedin.isNotBlank()) obj.put("linkedin", linkedin.trim())
  567 + if (xHandle.isNotBlank()) obj.put("x", xHandle.trim())
  568 + if (youtube.isNotBlank()) obj.put("youtube", youtube.trim())
  569 + if (xiaohongshu.isNotBlank()) obj.put("xiaohongshu", xiaohongshu.trim())
  570 + if (douyin.isNotBlank()) obj.put("douyin", douyin.trim())
  571 + if (weibo.isNotBlank()) obj.put("weibo", weibo.trim())
  572 + if (website.isNotBlank()) obj.put("website", website.trim())
  573 + if (notes.isNotBlank()) obj.put("notes", notes.trim())
  574 + return if (obj.length() == 0) null else obj.toString()
  575 + }
  576 +
  577 + AlertDialog(
  578 + onDismissRequest = onDismiss,
  579 + title = { Text("性格 & 社媒档案") },
  580 + text = {
  581 + Column(
  582 + modifier = Modifier.verticalScroll(rememberScrollState()),
  583 + verticalArrangement = Arrangement.spacedBy(8.dp),
  584 + ) {
  585 + Text("性格分析", style = MaterialTheme.typography.labelLarge)
  586 + OutlinedTextField(value = mbti, onValueChange = { mbti = it }, singleLine = true,
  587 + label = { Text("MBTI(如 INTJ)") }, modifier = Modifier.fillMaxWidth())
  588 + OutlinedTextField(value = bazi, onValueChange = { bazi = it }, singleLine = true,
  589 + label = { Text("八字") }, modifier = Modifier.fillMaxWidth())
  590 + OutlinedTextField(value = personality, onValueChange = { personality = it },
  591 + label = { Text("性格描述") }, minLines = 2, modifier = Modifier.fillMaxWidth())
  592 + Spacer(Modifier.height(4.dp))
  593 + Text("社媒账号", style = MaterialTheme.typography.labelLarge)
  594 + OutlinedTextField(value = blog, onValueChange = { blog = it }, singleLine = true,
  595 + label = { Text("Blog / 个人网站(完整 URL)") }, modifier = Modifier.fillMaxWidth())
  596 + OutlinedTextField(value = github, onValueChange = { github = it }, singleLine = true,
  597 + label = { Text("GitHub(用户名或 URL)") }, modifier = Modifier.fillMaxWidth())
  598 + OutlinedTextField(value = linkedin, onValueChange = { linkedin = it }, singleLine = true,
  599 + label = { Text("LinkedIn") }, modifier = Modifier.fillMaxWidth())
  600 + OutlinedTextField(value = xHandle, onValueChange = { xHandle = it }, singleLine = true,
  601 + label = { Text("X / Twitter(@handle 或 URL)") }, modifier = Modifier.fillMaxWidth())
  602 + OutlinedTextField(value = youtube, onValueChange = { youtube = it }, singleLine = true,
  603 + label = { Text("YouTube") }, modifier = Modifier.fillMaxWidth())
  604 + OutlinedTextField(value = xiaohongshu, onValueChange = { xiaohongshu = it }, singleLine = true,
  605 + label = { Text("小红书") }, modifier = Modifier.fillMaxWidth())
  606 + OutlinedTextField(value = douyin, onValueChange = { douyin = it }, singleLine = true,
  607 + label = { Text("抖音") }, modifier = Modifier.fillMaxWidth())
  608 + OutlinedTextField(value = weibo, onValueChange = { weibo = it }, singleLine = true,
  609 + label = { Text("微博") }, modifier = Modifier.fillMaxWidth())
  610 + OutlinedTextField(value = website, onValueChange = { website = it }, singleLine = true,
  611 + label = { Text("其他网站") }, modifier = Modifier.fillMaxWidth())
  612 + Spacer(Modifier.height(4.dp))
  613 + OutlinedTextField(value = notes, onValueChange = { notes = it },
  614 + label = { Text("备注") }, minLines = 2, modifier = Modifier.fillMaxWidth())
  615 + }
  616 + },
  617 + confirmButton = { TextButton(onClick = { onSave(buildJson()) }) { Text("保存") } },
  618 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  619 + )
  620 +}
  621 +
460 /** 编辑真名 + 简历(手填,本地保存)。 */ 622 /** 编辑真名 + 简历(手填,本地保存)。 */
461 @Composable 623 @Composable
462 private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { 624 private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
@@ -257,6 +257,13 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -257,6 +257,13 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
257 } 257 }
258 } 258 }
259 259
  260 + /** 结构化档案 JSON(MBTI/八字/性格分析/社媒账号);null = 清除。 */
  261 + fun updatePersonProfileJson(personId: Long, json: String?) {
  262 + viewModelScope.launch(Dispatchers.IO) {
  263 + repo.persons.updateProfileJson(personId, json?.ifBlank { null })
  264 + }
  265 + }
  266 +
260 /** 267 /**
261 * 读手机系统通讯录 → 内存列表(不入库),供「手机通讯录」多选页展示。 268 * 读手机系统通讯录 → 内存列表(不入库),供「手机通讯录」多选页展示。
262 * 一个联系人多号码只取第一条;无名字的跳过。调用方需先拿到 READ_CONTACTS。 269 * 一个联系人多号码只取第一条;无名字的跳过。调用方需先拿到 READ_CONTACTS。
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt
@@ -2,6 +2,7 @@ package com.lemon.lifelog.chat @@ -2,6 +2,7 @@ package com.lemon.lifelog.chat
2 2
3 import android.app.Application 3 import android.app.Application
4 import android.content.Context 4 import android.content.Context
  5 +import android.util.Log
5 import androidx.compose.runtime.getValue 6 import androidx.compose.runtime.getValue
6 import androidx.compose.runtime.mutableStateOf 7 import androidx.compose.runtime.mutableStateOf
7 import androidx.compose.runtime.setValue 8 import androidx.compose.runtime.setValue
@@ -10,7 +11,6 @@ import androidx.lifecycle.viewModelScope @@ -10,7 +11,6 @@ import androidx.lifecycle.viewModelScope
10 import com.lemon.lifelog.data.ChatConversation 11 import com.lemon.lifelog.data.ChatConversation
11 import com.lemon.lifelog.data.ChatMessage 12 import com.lemon.lifelog.data.ChatMessage
12 import com.lemon.lifelog.data.Repository 13 import com.lemon.lifelog.data.Repository
13 -import org.json.JSONArray  
14 import org.json.JSONObject 14 import org.json.JSONObject
15 import kotlinx.coroutines.Dispatchers 15 import kotlinx.coroutines.Dispatchers
16 import kotlinx.coroutines.launch 16 import kotlinx.coroutines.launch
@@ -127,7 +127,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { @@ -127,7 +127,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
127 streamingText = "" 127 streamingText = ""
128 try { 128 try {
129 if (toolMode) { 129 if (toolMode) {
130 - agenticLoop(target, cid, history) 130 + agenticLoopReAct(target, cid, history)
131 } else { 131 } else {
132 normalChat(text, target, cid, history.map { it.role to it.content }) 132 normalChat(text, target, cid, history.map { it.role to it.content })
133 } 133 }
@@ -171,58 +171,77 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { @@ -171,58 +171,77 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
171 } 171 }
172 172
173 /** 173 /**
174 - * 工具模式 agentic loop:  
175 - * 1. 发带 tools 的请求 → 若 LLM 返回 tool_calls,本地执行 → 追加结果 → 再请求  
176 - * 2. 直到 LLM 不再调工具(给出最终文字)或达到最大轮数  
177 - * 3. 只把 user 消息和最终 assistant 文字存 Room(中间工具消息不持久化) 174 + * ReAct 工具循环:用文本协议而非 native function calling,兼容所有网关/模型。
  175 + *
  176 + * 协议:系统提示要求模型用 "TOOL_CALL: {json}" 行声明工具调用;
  177 + * 应用解析这些行 → 执行 → 把 TOOL_RESULT 以 user 消息追回 → 继续;
  178 + * 模型回复中不再出现 TOOL_CALL 时即为最终答案。
178 */ 179 */
179 - private suspend fun agenticLoop( 180 + private suspend fun agenticLoopReAct(
180 target: ChatTarget, 181 target: ChatTarget,
181 cid: Long, 182 cid: Long,
182 history: List<ChatMessage>, 183 history: List<ChatMessage>,
183 ) { 184 ) {
184 - // 构建初始 messages(system prompt + 全部 Room 历史,已含刚存的 user 消息)  
185 - val messages: MutableList<JSONObject> = mutableListOf()  
186 - messages.add(JSONObject().put("role", "system").put("content", TOOL_SYSTEM_PROMPT))  
187 - history.forEach { m ->  
188 - messages.add(JSONObject().put("role", m.role).put("content", m.content)) 185 + val messages = mutableListOf<Pair<String, String>>()
  186 + messages.add("system" to REACT_SYSTEM_PROMPT)
  187 + // 历史里跳过之前的 system 消息(避免两个 system 冲突)
  188 + history.filter { it.role != "system" }.forEach { m ->
  189 + messages.add(m.role to m.content)
189 } 190 }
190 191
  192 + val toolLog = StringBuilder()
191 var finalText = "" 193 var finalText = ""
192 var rounds = 0 194 var rounds = 0
193 toolStatus = "思考中…" 195 toolStatus = "思考中…"
194 196
195 while (rounds < MAX_TOOL_ROUNDS) { 197 while (rounds < MAX_TOOL_ROUNDS) {
196 - val result = withContext(Dispatchers.IO) {  
197 - client.chatWithTools(target, messages, LifelogTools.SCHEMAS)  
198 - }  
199 - if (result.toolCalls.isNullOrEmpty()) {  
200 - finalText = result.text 198 + Log.d(TAG, "reactLoop round=$rounds msgs=${messages.size}")
  199 + streamingText = ""
  200 + val response = client.stream(target, messages) { delta -> streamingText += delta }
  201 + Log.d(TAG, "reactLoop round=$rounds response=${response.take(120)}")
  202 +
  203 + val calls = parseReActCalls(response)
  204 + Log.d(TAG, "reactLoop round=$rounds calls=${calls.size}")
  205 +
  206 + if (calls.isEmpty()) {
  207 + // 没有 TOOL_CALL → 这就是最终答案,去掉可能残留的 TOOL_CALL 行
  208 + finalText = response.lines()
  209 + .filter { !it.trimStart().startsWith("TOOL_CALL:") }
  210 + .joinToString("\n").trim()
201 break 211 break
202 } 212 }
203 - // assistant 消息(含 tool_calls,API 要求原样回传)  
204 - val assistantMsg = JSONObject().put("role", "assistant").put("content", result.text.ifBlank { "" })  
205 - result.rawToolCallsJson?.let { assistantMsg.put("tool_calls", it) }  
206 - messages.add(assistantMsg)  
207 -  
208 - // 逐个执行工具  
209 - for (call in result.toolCalls) {  
210 - toolStatus = "⚙ ${call.name}"  
211 - streamingText = "调用工具:${call.name}\n"  
212 - val args = runCatching { JSONObject(call.arguments) }.getOrElse { JSONObject() }  
213 - val toolResult = LifelogTools.execute(repo, call.name, args)  
214 - streamingText += toolResult.take(200)  
215 - messages.add(JSONObject()  
216 - .put("role", "tool")  
217 - .put("tool_call_id", call.id)  
218 - .put("content", toolResult)) 213 +
  214 + // 执行所有工具,收集结果
  215 + val resultBlock = StringBuilder()
  216 + for ((name, args) in calls) {
  217 + toolStatus = "⚙ $name"
  218 + streamingText = "⚙ 调用:$name…"
  219 + Log.d(TAG, "exec react tool $name args=$args")
  220 + val result = withContext(Dispatchers.IO) { LifelogTools.execute(repo, name, args) }
  221 + Log.d(TAG, "react tool $name result=${result.take(120)}")
  222 + toolLog.appendLine("**[$name]** ${result.take(600)}")
  223 + resultBlock.appendLine("[$name 结果]")
  224 + resultBlock.appendLine(result.take(1000))
219 } 225 }
  226 +
  227 + // 把本轮对话追加进 messages(assistant 回复 + user 注入工具结果)
  228 + messages.add("assistant" to response)
  229 + messages.add("user" to "TOOL_RESULT:\n${resultBlock.toString().trim()}")
220 rounds++ 230 rounds++
221 toolStatus = "整理回复中(第 $rounds 轮)…" 231 toolStatus = "整理回复中(第 $rounds 轮)…"
222 streamingText = toolStatus 232 streamingText = toolStatus
223 } 233 }
224 234
225 - if (finalText.isBlank()) finalText = "(未获得有效回复,请重试或检查服务商是否支持 function calling)" 235 + // 组装最终消息:工具摘要 + LLM 文字
  236 + if (finalText.isBlank() && toolLog.isEmpty()) {
  237 + finalText = "(未获得有效回复,请重试)"
  238 + } else if (finalText.isBlank()) {
  239 + finalText = toolLog.toString().trimEnd()
  240 + } else if (toolLog.isNotEmpty()) {
  241 + finalText = toolLog.toString().trimEnd() + "\n\n---\n\n" + finalText
  242 + }
  243 +
  244 + streamingText = finalText
226 withContext(Dispatchers.IO) { 245 withContext(Dispatchers.IO) {
227 repo.chats.insertMessage(ChatMessage( 246 repo.chats.insertMessage(ChatMessage(
228 conversationId = cid, role = "assistant", 247 conversationId = cid, role = "assistant",
@@ -233,16 +252,59 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { @@ -233,16 +252,59 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
233 } 252 }
234 } 253 }
235 254
  255 + /** 从模型回复文本里提取 "TOOL_CALL: {json}" 行 */
  256 + private fun parseReActCalls(text: String): List<Pair<String, JSONObject>> =
  257 + text.lines()
  258 + .filter { it.trimStart().startsWith("TOOL_CALL:") }
  259 + .mapNotNull { line ->
  260 + runCatching {
  261 + val json = JSONObject(line.substringAfter("TOOL_CALL:").trim())
  262 + val name = json.getString("name")
  263 + val args = json.optJSONObject("args") ?: JSONObject()
  264 + name to args
  265 + }.getOrNull()
  266 + }
  267 +
236 companion object { 268 companion object {
  269 + private const val TAG = "ChatViewModel"
237 private const val K_LIFELOG = "lifelog_mode" 270 private const val K_LIFELOG = "lifelog_mode"
238 private const val K_TOOL = "tool_mode" 271 private const val K_TOOL = "tool_mode"
239 private const val K_TARGET = "selected_target" 272 private const val K_TARGET = "selected_target"
240 private const val K_CONV = "current_conv" 273 private const val K_CONV = "current_conv"
241 private const val MAX_TOOL_ROUNDS = 8 274 private const val MAX_TOOL_ROUNDS = 8
242 - private val TOOL_SYSTEM_PROMPT = """  
243 - 你是用户的 lifelog AI 助手,可以访问用户 24/7 录音转写、会话纪要、联系人档案和相册。  
244 - 当用户询问记忆、联系人或照片时,请主动调用相应工具获取真实数据后再回答。  
245 - 多步骤问题可多次调用工具。最终回复用中文,简洁清晰。 275 + private val REACT_SYSTEM_PROMPT = """
  276 + 你是用户的 lifelog AI 助手,可访问以下工具查询真实数据(禁止凭空编造):
  277 +
  278 + 【录音 & 纪要】
  279 + ① search_transcripts — 搜录音转写(args: query, speaker可选, limit默认12)
  280 + ② get_summaries — 最近会话纪要(args: limit默认5)
  281 +
  282 + 【联系人 & 关系】
  283 + ③ get_person — 查联系人档案(args: name)
  284 + ④ list_persons — 列 CRM 人物(args: managed_only默认true)
  285 + ⑤ get_relations — 某人的共现关系图(args: person_name)
  286 + ⑥ get_mentions — 录音中提到某人的片段(args: person_name, limit默认10)
  287 +
  288 + 【微信聊天 & 朋友圈】
  289 + ⑦ search_wechat_chat — 搜微信私聊(args: query, person_name可选, limit默认10)
  290 + ⑧ search_group_chat — 搜微信群聊(args: query, limit默认10)
  291 + ⑨ get_moments — 某人朋友圈文字(args: person_name, keyword可选, limit默认20)
  292 + ⑩ search_moments — 全局搜朋友圈(args: query, limit默认10)
  293 +
  294 + 【相册 & 待跟进】
  295 + ⑪ search_photos — 关键词搜相册(args: query, limit默认8)
  296 + ⑫ get_person_photos — 某人出现的照片(args: person_name, limit默认10)
  297 + ⑬ get_followups — 待跟进事项(args: 无)
  298 +
  299 + 需要查询时,输出(每个工具独占一行,严格 JSON):
  300 + TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}}
  301 +
  302 + 示例:
  303 + TOOL_CALL: {"name": "search_transcripts", "args": {"query": "火星人", "limit": 5}}
  304 + TOOL_CALL: {"name": "get_person", "args": {"name": "张三"}}
  305 +
  306 + 收到 TOOL_RESULT 后继续分析,可再次调用工具。
  307 + TOOL_CALL 行由系统截取执行,最终答案里不要重复输出。最终答案用中文,简洁清晰。
246 """.trimIndent() 308 """.trimIndent()
247 } 309 }
248 } 310 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
@@ -45,11 +45,21 @@ object LifelogTools { @@ -45,11 +45,21 @@ object LifelogTools {
45 )) 45 ))
46 put(schema( 46 put(schema(
47 name = "get_person", 47 name = "get_person",
48 - desc = "按姓名或备注查找联系人档案(基本资料、AI 印象、简历、关联声纹/人脸)", 48 + desc = "按姓名或备注查找联系人档案(基本资料、AI 印象、简历、结构化档案含 MBTI/社媒等)",
49 props = mapOf("name" to prop("string", "人物姓名或备注名")), 49 props = mapOf("name" to prop("string", "人物姓名或备注名")),
50 required = listOf("name"), 50 required = listOf("name"),
51 )) 51 ))
52 put(schema( 52 put(schema(
  53 + name = "update_person_profile",
  54 + desc = "更新联系人的结构化档案字段(MBTI、八字、性格描述、社媒账号等)",
  55 + props = mapOf(
  56 + "name" to prop("string", "人物姓名或备注名"),
  57 + "field" to prop("string", "要更新的字段:mbti / bazi / personality / blog / github / linkedin / x / youtube / xiaohongshu / douyin / weibo / website / notes"),
  58 + "value" to prop("string", "新值;空字符串表示清除该字段"),
  59 + ),
  60 + required = listOf("name", "field", "value"),
  61 + ))
  62 + put(schema(
53 name = "list_persons", 63 name = "list_persons",
54 desc = "列出 CRM 中的联系人(默认只列我管理的人)", 64 desc = "列出 CRM 中的联系人(默认只列我管理的人)",
55 props = mapOf( 65 props = mapOf(
@@ -78,6 +88,70 @@ object LifelogTools { @@ -78,6 +88,70 @@ object LifelogTools {
78 props = emptyMap(), 88 props = emptyMap(),
79 required = emptyList(), 89 required = emptyList(),
80 )) 90 ))
  91 + put(schema(
  92 + name = "search_wechat_chat",
  93 + desc = "在微信私聊记录中按关键词搜索消息(不含群聊)",
  94 + props = mapOf(
  95 + "query" to prop("string", "搜索关键词"),
  96 + "person_name" to prop("string", "限定某人的对话,可选"),
  97 + "limit" to prop("integer", "返回条数,默认 10"),
  98 + ),
  99 + required = listOf("query"),
  100 + ))
  101 + put(schema(
  102 + name = "search_group_chat",
  103 + desc = "在微信群聊记录中按关键词搜索消息",
  104 + props = mapOf(
  105 + "query" to prop("string", "搜索关键词"),
  106 + "limit" to prop("integer", "返回条数,默认 10"),
  107 + ),
  108 + required = listOf("query"),
  109 + ))
  110 + put(schema(
  111 + name = "get_moments",
  112 + desc = "获取某人的微信朋友圈文字动态(按姓名查找,按时间倒序)",
  113 + props = mapOf(
  114 + "person_name" to prop("string", "人物姓名或昵称"),
  115 + "keyword" to prop("string", "可选:只返回含此关键词的朋友圈"),
  116 + "limit" to prop("integer", "返回条数,默认 20"),
  117 + ),
  118 + required = listOf("person_name"),
  119 + ))
  120 + put(schema(
  121 + name = "search_moments",
  122 + desc = "在所有联系人的朋友圈文字中全局关键词搜索",
  123 + props = mapOf(
  124 + "query" to prop("string", "搜索关键词"),
  125 + "limit" to prop("integer", "返回条数,默认 10"),
  126 + ),
  127 + required = listOf("query"),
  128 + ))
  129 + put(schema(
  130 + name = "get_person_photos",
  131 + desc = "获取某人出现的照片(人脸匹配),含 VL 场景描述(需要索引完成)",
  132 + props = mapOf(
  133 + "person_name" to prop("string", "人物姓名"),
  134 + "limit" to prop("integer", "返回张数,默认 10"),
  135 + ),
  136 + required = listOf("person_name"),
  137 + ))
  138 + put(schema(
  139 + name = "get_relations",
  140 + desc = "获取某人的共现关系(在录音中一起被提到的人,按共现权重排序)",
  141 + props = mapOf(
  142 + "person_name" to prop("string", "人物姓名"),
  143 + ),
  144 + required = listOf("person_name"),
  145 + ))
  146 + put(schema(
  147 + name = "get_mentions",
  148 + desc = "查询录音中提到某人的片段(区别于声纹:是「被说到」而非「谁说的」)",
  149 + props = mapOf(
  150 + "person_name" to prop("string", "人物姓名"),
  151 + "limit" to prop("integer", "返回条数,默认 10"),
  152 + ),
  153 + required = listOf("person_name"),
  154 + ))
81 } 155 }
82 } 156 }
83 157
@@ -106,10 +180,18 @@ object LifelogTools { @@ -106,10 +180,18 @@ object LifelogTools {
106 when (name) { 180 when (name) {
107 "search_transcripts" -> searchTranscripts(repo, args) 181 "search_transcripts" -> searchTranscripts(repo, args)
108 "get_person" -> getPerson(repo, args) 182 "get_person" -> getPerson(repo, args)
  183 + "update_person_profile" -> updatePersonProfile(repo, args)
109 "list_persons" -> listPersons(repo, args) 184 "list_persons" -> listPersons(repo, args)
110 "get_summaries" -> getSummaries(repo, args) 185 "get_summaries" -> getSummaries(repo, args)
111 "search_photos" -> searchPhotos(repo, args) 186 "search_photos" -> searchPhotos(repo, args)
112 "get_followups" -> getFollowups(repo) 187 "get_followups" -> getFollowups(repo)
  188 + "search_wechat_chat" -> searchWechatChat(repo, args)
  189 + "search_group_chat" -> searchGroupChat(repo, args)
  190 + "get_moments" -> getMoments(repo, args)
  191 + "search_moments" -> searchMoments(repo, args)
  192 + "get_person_photos" -> getPersonPhotos(repo, args)
  193 + "get_relations" -> getRelations(repo, args)
  194 + "get_mentions" -> getMentions(repo, args)
113 else -> "未知工具:$name" 195 else -> "未知工具:$name"
114 } 196 }
115 } catch (t: Throwable) { 197 } catch (t: Throwable) {
@@ -154,6 +236,19 @@ object LifelogTools { @@ -154,6 +236,19 @@ object LifelogTools {
154 if (p.wechatFriend == true) appendLine("微信好友:是") 236 if (p.wechatFriend == true) appendLine("微信好友:是")
155 p.impression?.takeIf { it.isNotBlank() }?.let { appendLine("AI 印象:${it.take(400)}") } 237 p.impression?.takeIf { it.isNotBlank() }?.let { appendLine("AI 印象:${it.take(400)}") }
156 p.resume?.takeIf { it.isNotBlank() }?.let { appendLine("简历:${it.take(300)}") } 238 p.resume?.takeIf { it.isNotBlank() }?.let { appendLine("简历:${it.take(300)}") }
  239 + // 结构化档案
  240 + p.profileJson?.takeIf { it.isNotBlank() }?.let { jsonStr ->
  241 + try {
  242 + val obj = JSONObject(jsonStr)
  243 + appendLine("── 结构化档案 ──")
  244 + obj.optString("mbti").takeIf { it.isNotBlank() }?.let { appendLine("MBTI:$it") }
  245 + obj.optString("bazi").takeIf { it.isNotBlank() }?.let { appendLine("八字:$it") }
  246 + obj.optString("personality").takeIf { it.isNotBlank() }?.let { appendLine("性格:${it.take(200)}") }
  247 + val socialKeys = listOf("github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "blog", "website")
  248 + socialKeys.forEach { k -> obj.optString(k).takeIf { it.isNotBlank() }?.let { appendLine("$k:$it") } }
  249 + obj.optString("notes").takeIf { it.isNotBlank() }?.let { appendLine("备注:${it.take(200)}") }
  250 + } catch (_: Exception) {}
  251 + }
157 val tags = buildList { 252 val tags = buildList {
158 if (p.managed) add("CRM 管理中") 253 if (p.managed) add("CRM 管理中")
159 if (p.voiceSpeakerId != null) add("有声纹") 254 if (p.voiceSpeakerId != null) add("有声纹")
@@ -165,6 +260,23 @@ object LifelogTools { @@ -165,6 +260,23 @@ object LifelogTools {
165 } 260 }
166 } 261 }
167 262
  263 + private suspend fun updatePersonProfile(repo: Repository, args: JSONObject): String {
  264 + val q = args.optString("name", "").ifBlank { return "请提供人物姓名" }
  265 + val field = args.optString("field", "").ifBlank { return "请提供字段名(如 mbti, github, personality 等)" }
  266 + val value = args.optString("value", "")
  267 + val allowed = setOf("mbti", "bazi", "personality", "blog", "github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "website", "notes")
  268 + if (field !in allowed) return "不支持的字段「$field」。可用字段:${allowed.joinToString(", ")}"
  269 + val persons = repo.persons.all().filter { p ->
  270 + p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true || p.remark?.contains(q) == true
  271 + }
  272 + if (persons.isEmpty()) return "未找到与「$q」匹配的联系人。"
  273 + val p = persons.first()
  274 + val obj = try { JSONObject(p.profileJson ?: "{}") } catch (_: Exception) { JSONObject() }
  275 + if (value.isBlank()) obj.remove(field) else obj.put(field, value.trim())
  276 + repo.persons.updateProfileJson(p.id, if (obj.length() == 0) null else obj.toString())
  277 + return "已更新 ${p.name} 的档案字段 $field${if (value.isBlank()) "(已清除)" else " = ${value.trim()}"}"
  278 + }
  279 +
168 private suspend fun listPersons(repo: Repository, args: JSONObject): String { 280 private suspend fun listPersons(repo: Repository, args: JSONObject): String {
169 val managedOnly = args.optBoolean("managed_only", true) 281 val managedOnly = args.optBoolean("managed_only", true)
170 val persons = repo.persons.all().let { if (managedOnly) it.filter { p -> p.managed } else it } 282 val persons = repo.persons.all().let { if (managedOnly) it.filter { p -> p.managed } else it }
@@ -232,4 +344,149 @@ object LifelogTools { @@ -232,4 +344,149 @@ object LifelogTools {
232 } 344 }
233 } 345 }
234 } 346 }
  347 +
  348 + // ── 微信聊天 ──────────────────────────────────────────────────────────────
  349 +
  350 + private suspend fun searchWechatChat(repo: Repository, args: JSONObject): String {
  351 + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" }
  352 + val personName = args.optString("person_name", "").ifBlank { null }
  353 + val limit = args.optInt("limit", 10).coerceIn(1, 30)
  354 +
  355 + val allPersons = repo.persons.all()
  356 + val wxidToName = allPersons
  357 + .filter { it.contactRef?.startsWith("wechat:") == true }
  358 + .associate { it.contactRef!!.removePrefix("wechat:") to (it.realName ?: it.nickname ?: it.name) }
  359 +
  360 + var msgs = repo.wechatMessages.searchDirect(query, limit * 3)
  361 + if (personName != null) {
  362 + val wxid = allPersons.firstOrNull { p ->
  363 + p.name.contains(personName) || p.realName?.contains(personName) == true ||
  364 + p.nickname?.contains(personName) == true
  365 + }?.contactRef?.removePrefix("wechat:")
  366 + if (wxid != null) msgs = msgs.filter { it.talker == wxid }
  367 + }
  368 + msgs = msgs.take(limit)
  369 + if (msgs.isEmpty()) return "未找到包含「$query」的微信私聊记录。"
  370 + return buildString {
  371 + appendLine("找到 ${msgs.size} 条微信私聊(关键词:「$query」):")
  372 + msgs.forEach { m ->
  373 + val name = wxidToName[m.talker] ?: m.talker
  374 + val dir = if (m.isSend) "我→$name" else "$name→我"
  375 + appendLine("[${TSL.format(Date(m.createTime))}] $dir:${m.content.take(200)}")
  376 + }
  377 + }
  378 + }
  379 +
  380 + private suspend fun searchGroupChat(repo: Repository, args: JSONObject): String {
  381 + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" }
  382 + val limit = args.optInt("limit", 10).coerceIn(1, 30)
  383 + val msgs = repo.wechatMessages.searchGroup(query, limit)
  384 + if (msgs.isEmpty()) return "未找到包含「$query」的微信群聊记录。"
  385 + return buildString {
  386 + appendLine("找到 ${msgs.size} 条群聊消息(关键词:「$query」):")
  387 + msgs.forEach { m ->
  388 + val dir = if (m.isSend) "我" else m.talker
  389 + appendLine("[${TSL.format(Date(m.createTime))}] 群(${m.talker}) $dir:${m.content.take(200)}")
  390 + }
  391 + }
  392 + }
  393 +
  394 + // ── 朋友圈 ───────────────────────────────────────────────────────────────
  395 +
  396 + private suspend fun getMoments(repo: Repository, args: JSONObject): String {
  397 + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" }
  398 + val keyword = args.optString("keyword", "").ifBlank { null }
  399 + val limit = args.optInt("limit", 20).coerceIn(1, 50)
  400 +
  401 + val person = repo.persons.all().firstOrNull { p ->
  402 + p.name.contains(name) || p.realName?.contains(name) == true ||
  403 + p.nickname?.contains(name) == true || p.remark?.contains(name) == true
  404 + } ?: return "未找到与「$name」匹配的联系人。"
  405 + val wxid = person.contactRef?.removePrefix("wechat:")
  406 + ?: return "「${person.name}」没有微信 ID,无法查询朋友圈。"
  407 +
  408 + val all = repo.moments.byUser(wxid, if (keyword != null) limit * 5 else limit)
  409 + val moments = if (keyword != null) all.filter { it.text.contains(keyword) }.take(limit) else all
  410 + if (moments.isEmpty()) return "「${person.name}」暂无朋友圈记录${if (keyword != null) "(含「$keyword」)" else ""}。"
  411 + return buildString {
  412 + appendLine("${person.name} 的朋友圈(${moments.size} 条):")
  413 + moments.forEach { m ->
  414 + appendLine("[${TS.format(Date(m.createTime))}] ${m.text.take(300)}")
  415 + appendLine()
  416 + }
  417 + }
  418 + }
  419 +
  420 + private suspend fun searchMoments(repo: Repository, args: JSONObject): String {
  421 + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" }
  422 + val limit = args.optInt("limit", 10).coerceIn(1, 30)
  423 + val moments = repo.moments.searchText(query, limit)
  424 + if (moments.isEmpty()) return "未找到包含「$query」的朋友圈内容。"
  425 + val wxidToName = repo.persons.all()
  426 + .filter { it.contactRef?.startsWith("wechat:") == true }
  427 + .associate { it.contactRef!!.removePrefix("wechat:") to (it.realName ?: it.nickname ?: it.name) }
  428 + return buildString {
  429 + appendLine("找到 ${moments.size} 条朋友圈(关键词:「$query」):")
  430 + moments.forEach { m ->
  431 + val who = wxidToName[m.userName] ?: m.userName
  432 + appendLine("[${TS.format(Date(m.createTime))}] $who:${m.text.take(300)}")
  433 + appendLine()
  434 + }
  435 + }
  436 + }
  437 +
  438 + // ── 相册 + 关系图 ─────────────────────────────────────────────────────────
  439 +
  440 + private suspend fun getPersonPhotos(repo: Repository, args: JSONObject): String {
  441 + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" }
  442 + val limit = args.optInt("limit", 10).coerceIn(1, 30)
  443 + val person = repo.persons.all().firstOrNull { p ->
  444 + p.name.contains(name) || p.realName?.contains(name) == true ||
  445 + p.nickname?.contains(name) == true
  446 + } ?: return "未找到与「$name」匹配的联系人。"
  447 + val photos = repo.photos.observePhotosOfPerson(person.id).first().take(limit)
  448 + if (photos.isEmpty()) return "「${person.name}」暂无人脸匹配照片(需完成相册扫描)。"
  449 + return buildString {
  450 + appendLine("「${person.name}」出现的照片(${photos.size} 张):")
  451 + photos.forEach { p ->
  452 + val caption = p.caption?.take(120) ?: "(暂无 VL 描述)"
  453 + val state = when (p.scanState) { 2 -> "已描述" 1 -> "已识别" else -> "待处理" }
  454 + appendLine("[${TS.format(Date(p.takenTs))} $state] $caption")
  455 + }
  456 + }
  457 + }
  458 +
  459 + private suspend fun getRelations(repo: Repository, args: JSONObject): String {
  460 + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" }
  461 + val person = repo.persons.all().firstOrNull { p ->
  462 + p.name.contains(name) || p.realName?.contains(name) == true ||
  463 + p.nickname?.contains(name) == true
  464 + } ?: return "未找到与「$name」匹配的联系人。"
  465 + val relations = repo.relations.observeRelatedTo(person.id).first()
  466 + if (relations.isEmpty()) return "「${person.name}」暂无共现关系(需先运行关系图构建)。"
  467 + return buildString {
  468 + appendLine("「${person.name}」的共现关系(${relations.size} 人):")
  469 + relations.take(15).forEach { r ->
  470 + appendLine("- ${r.name}(共现权重 ${r.weight})")
  471 + }
  472 + }
  473 + }
  474 +
  475 + private suspend fun getMentions(repo: Repository, args: JSONObject): String {
  476 + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" }
  477 + val limit = args.optInt("limit", 10).coerceIn(1, 30)
  478 + val person = repo.persons.all().firstOrNull { p ->
  479 + p.name.contains(name) || p.realName?.contains(name) == true ||
  480 + p.nickname?.contains(name) == true
  481 + } ?: return "未找到与「$name」匹配的联系人。"
  482 + val segments = repo.mentions.observeMentionsOf(person.id, limit).first()
  483 + if (segments.isEmpty()) return "录音中未找到提到「${person.name}」的片段。"
  484 + return buildString {
  485 + appendLine("录音中提到「${person.name}」的片段(${segments.size} 条):")
  486 + segments.forEach { s ->
  487 + val who = s.speakerName ?: s.personName ?: "未知说话人"
  488 + appendLine("[${TSL.format(Date(s.startTs))}] $who:${s.text?.trim()?.take(200)}")
  489 + }
  490 + }
  491 + }
235 } 492 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
@@ -28,7 +28,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @@ -28,7 +28,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
28 Topic::class, 28 Topic::class,
29 TopicMention::class, 29 TopicMention::class,
30 ], 30 ],
31 - version = 20, 31 + version = 21,
32 exportSchema = false, 32 exportSchema = false,
33 ) 33 )
34 abstract class AppDatabase : RoomDatabase() { 34 abstract class AppDatabase : RoomDatabase() {
@@ -223,6 +223,13 @@ abstract class AppDatabase : RoomDatabase() { @@ -223,6 +223,13 @@ abstract class AppDatabase : RoomDatabase() {
223 } 223 }
224 } 224 }
225 225
  226 + /** v0.9 结构化档案:persons.profileJson(MBTI/八字/性格/社媒,JSON blob,AI 可 query+update)。 */
  227 + private val MIGRATION_20_21 = object : Migration(20, 21) {
  228 + override fun migrate(db: SupportSQLiteDatabase) {
  229 + db.execSQL("ALTER TABLE `persons` ADD COLUMN `profileJson` TEXT")
  230 + }
  231 + }
  232 +
226 fun get(ctx: Context): AppDatabase = 233 fun get(ctx: Context): AppDatabase =
227 instance ?: synchronized(this) { 234 instance ?: synchronized(this) {
228 instance ?: Room.databaseBuilder( 235 instance ?: Room.databaseBuilder(
@@ -230,7 +237,7 @@ abstract class AppDatabase : RoomDatabase() { @@ -230,7 +237,7 @@ abstract class AppDatabase : RoomDatabase() {
230 AppDatabase::class.java, 237 AppDatabase::class.java,
231 "lifelog.db", 238 "lifelog.db",
232 ) 239 )
233 - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20) 240 + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21)
234 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, 241 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
235 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 242 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
236 .build() 243 .build()
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
@@ -251,6 +251,50 @@ interface SegmentDao { @@ -251,6 +251,50 @@ interface SegmentDao {
251 """, 251 """,
252 ) 252 )
253 fun searchTimeline(query: String, limit: Int = 200): Flow<List<SegmentRow>> 253 fun searchTimeline(query: String, limit: Int = 200): Flow<List<SegmentRow>>
  254 +
  255 + /** 按时间段查录音(search_by_time 工具用)。 */
  256 + @Query(
  257 + """
  258 + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs,
  259 + s.audioStartMs AS audioStartMs,
  260 + t.text AS text, s.audioPath AS audioPath,
  261 + s.speakerId AS speakerId, s.speakerSim AS speakerSim,
  262 + sp.name AS speakerName,
  263 + p.id AS personId, p.name AS personName
  264 + FROM segments s
  265 + LEFT JOIN transcripts t ON t.segmentId = s.id
  266 + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId
  267 + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId
  268 + WHERE s.startTs BETWEEN :start AND :end
  269 + ORDER BY s.startTs ASC
  270 + LIMIT :limit
  271 + """,
  272 + )
  273 + suspend fun searchByTimeRange(start: Long, end: Long, limit: Int): List<SegmentRow>
  274 +
  275 + /** 某段附近 N 段(上下文查看):取 [ts-window, ts+window] 内的段,时间正序。 */
  276 + @Query(
  277 + """
  278 + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs,
  279 + s.audioStartMs AS audioStartMs,
  280 + t.text AS text, s.audioPath AS audioPath,
  281 + s.speakerId AS speakerId, s.speakerSim AS speakerSim,
  282 + sp.name AS speakerName,
  283 + p.id AS personId, p.name AS personName
  284 + FROM segments s
  285 + LEFT JOIN transcripts t ON t.segmentId = s.id
  286 + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId
  287 + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId
  288 + WHERE s.startTs BETWEEN :start AND :end
  289 + ORDER BY s.startTs ASC
  290 + LIMIT :limit
  291 + """,
  292 + )
  293 + suspend fun contextAround(start: Long, end: Long, limit: Int): List<SegmentRow>
  294 +
  295 + /** 按 id 获取单段基本信息(用于定位 startTs 后再查上下文)。 */
  296 + @Query("SELECT id, startTs, speakerId FROM segments WHERE id = :id")
  297 + suspend fun getSegMeta(id: Long): SegMeta?
254 } 298 }
255 299
256 @Dao 300 @Dao
@@ -598,6 +642,10 @@ interface PersonDao { @@ -598,6 +642,10 @@ interface PersonDao {
598 @Query("UPDATE persons SET wechatFriend = :friend WHERE id = :id") 642 @Query("UPDATE persons SET wechatFriend = :friend WHERE id = :id")
599 suspend fun setWechatFriend(id: Long, friend: Boolean?) 643 suspend fun setWechatFriend(id: Long, friend: Boolean?)
600 644
  645 + /** 结构化档案 JSON(MBTI/八字/社媒,AI 可 query+update)。 */
  646 + @Query("UPDATE persons SET profileJson = :json WHERE id = :id")
  647 + suspend fun updateProfileJson(id: Long, json: String?)
  648 +
601 /** 合并人物:写回合并后的整条记录(按主键更新)。 */ 649 /** 合并人物:写回合并后的整条记录(按主键更新)。 */
602 @Update 650 @Update
603 suspend fun update(p: Person) 651 suspend fun update(p: Person)
@@ -817,6 +865,10 @@ interface MomentDao { @@ -817,6 +865,10 @@ interface MomentDao {
817 865
818 @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC LIMIT :n") 866 @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC LIMIT :n")
819 suspend fun byUser(u: String, n: Int): List<Moment> 867 suspend fun byUser(u: String, n: Int): List<Moment>
  868 +
  869 + /** 全库关键词搜索朋友圈文字。 */
  870 + @Query("SELECT * FROM moments WHERE text LIKE '%' || :q || '%' ORDER BY createTime DESC LIMIT :n")
  871 + suspend fun searchText(q: String, n: Int): List<Moment>
820 } 872 }
821 873
822 @Dao 874 @Dao
@@ -837,6 +889,22 @@ interface WechatMessageDao { @@ -837,6 +889,22 @@ interface WechatMessageDao {
837 /** 某联系人(wxid)的聊天,时间正序。 */ 889 /** 某联系人(wxid)的聊天,时间正序。 */
838 @Query("SELECT * FROM wechat_messages WHERE talker = :talker ORDER BY createTime ASC") 890 @Query("SELECT * FROM wechat_messages WHERE talker = :talker ORDER BY createTime ASC")
839 fun observeByTalker(talker: String): Flow<List<WechatMessage>> 891 fun observeByTalker(talker: String): Flow<List<WechatMessage>>
  892 +
  893 + /** 关键词搜索私聊(排除群聊 talker 含 @chatroom)。 */
  894 + @Query("""
  895 + SELECT * FROM wechat_messages
  896 + WHERE content LIKE '%' || :q || '%' AND talker NOT LIKE '%@chatroom'
  897 + ORDER BY createTime DESC LIMIT :n
  898 + """)
  899 + suspend fun searchDirect(q: String, n: Int): List<WechatMessage>
  900 +
  901 + /** 关键词搜索群聊(talker 含 @chatroom)。 */
  902 + @Query("""
  903 + SELECT * FROM wechat_messages
  904 + WHERE content LIKE '%' || :q || '%' AND talker LIKE '%@chatroom'
  905 + ORDER BY createTime DESC LIMIT :n
  906 + """)
  907 + suspend fun searchGroup(q: String, n: Int): List<WechatMessage>
840 } 908 }
841 909
842 @Dao 910 @Dao
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
@@ -115,6 +115,9 @@ data class Person( @@ -115,6 +115,9 @@ data class Person(
115 val resume: String? = null, // 简历 / 备注(职业、公司、关系等手填长文) 115 val resume: String? = null, // 简历 / 备注(职业、公司、关系等手填长文)
116 /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ 116 /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */
117 val impression: String? = null, 117 val impression: String? = null,
  118 + /** 结构化档案(JSON blob):MBTI、八字、性格分析、社媒账号等,AI 可 query+update。字段:
  119 + * mbti, bazi, personality, blog, github, linkedin, x, youtube, xiaohongshu, douyin, weibo, website, notes */
  120 + val profileJson: String? = null,
118 /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ 121 /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */
119 val lastAccess: Long = 0, 122 val lastAccess: Long = 0,
120 /** 是否纳入「我管理的人物」:微信导入默认 false(只是池子),勾选/标人脸/绑声纹/手动建 → true。 123 /** 是否纳入「我管理的人物」:微信导入默认 false(只是池子),勾选/标人脸/绑声纹/手动建 → true。