From 8f47214f811c931bada0a56ce9240980eccbc4bc Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 10 Jun 2026 14:58:02 +0800 Subject: [PATCH] feat(crm/profile): 结构化档案 JSON + 性格/社媒 UI + ReAct 工具循环 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 7 +++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 11 +++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 3 +++ 7 files changed, 610 insertions(+), 44 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt index f0dfcb7..cb12201 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -58,7 +58,9 @@ import android.widget.Toast import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.layout.ContentScale +import org.json.JSONObject import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage @@ -112,6 +114,7 @@ internal fun PersonProfileScreen( var bindVoice by remember { mutableStateOf(false) } var recordVoice by remember { mutableStateOf(false) } var editProfile by remember { mutableStateOf(false) } + var editProfileJson by remember { mutableStateOf(false) } val player = remember { SegmentPlayer() } var playingId by remember { mutableStateOf(null) } DisposableEffect(player) { onDispose { player.stop() } } @@ -177,7 +180,7 @@ internal fun PersonProfileScreen( } } when (tab) { - 0 -> item(key = "resume") { person?.let { p -> ResumeTab(p, onEdit = { editProfile = true }, onEnrich = { vm.enrichImpression(personId) }) } } + 0 -> item(key = "resume") { person?.let { p -> ResumeTab(p, onEdit = { editProfile = true }, onEditProfile = { editProfileJson = true }, onEnrich = { vm.enrichImpression(personId) }) } } 1 -> if (fPhotos.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") } else items(fPhotos.chunked(3), key = { it.first().id }) { rowPhotos -> Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { @@ -250,6 +253,16 @@ internal fun PersonProfileScreen( } } + if (editProfileJson) { + person?.let { p -> + EditProfileJsonDialog( + p = p, + onDismiss = { editProfileJson = false }, + onSave = { json -> vm.updatePersonProfileJson(personId, json); editProfileJson = false }, + ) + } + } + } /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ @@ -399,9 +412,13 @@ private fun ProfileHero( } } -/** 「简历」tab:真名 + 简历正文 + AI 印象(只读展示;编辑/生成走按钮)。外层 LazyColumn 负责滚动。 */ +/** 「简历」tab:真名 + 简历正文 + 性格分析 + 社媒账号 + AI 印象(只读展示;编辑走按钮)。 */ @Composable -private fun ResumeTab(p: Person, onEdit: () -> Unit, onEnrich: () -> Unit) { +private fun ResumeTab(p: Person, onEdit: () -> Unit, onEditProfile: () -> Unit, onEnrich: () -> Unit) { + val uriHandler = LocalUriHandler.current + val profile = remember(p.profileJson) { + p.profileJson?.takeIf { it.isNotBlank() }?.let { runCatching { JSONObject(it) }.getOrNull() } + } Column( modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -425,6 +442,68 @@ private fun ResumeTab(p: Person, onEdit: () -> Unit, onEnrich: () -> Unit) { } } } + // 性格分析卡 + val mbti = profile?.optString("mbti").orEmpty() + val bazi = profile?.optString("bazi").orEmpty() + val personality = profile?.optString("personality").orEmpty() + if (mbti.isNotBlank() || bazi.isNotBlank() || personality.isNotBlank()) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("🔮 性格分析", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(6.dp)) + if (mbti.isNotBlank()) { + Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(50)) { + Text("MBTI: $mbti", style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) + } + Spacer(Modifier.height(4.dp)) + } + if (bazi.isNotBlank()) Text("八字:$bazi", style = MaterialTheme.typography.bodySmall) + if (personality.isNotBlank()) { Spacer(Modifier.height(4.dp)); Text(personality, style = MaterialTheme.typography.bodyMedium) } + } + } + } + // 社媒账号卡 + val socialKeys = listOf( + "blog" to "📝 Blog", "github" to "💻 GitHub", "linkedin" to "💼 LinkedIn", + "x" to "🐦 X", "youtube" to "▶ YouTube", "xiaohongshu" to "📕 小红书", + "douyin" to "🎵 抖音", "weibo" to "🐤 微博", "website" to "🌐 网站", + ) + val socials = socialKeys.mapNotNull { (key, label) -> + profile?.optString(key)?.takeIf { it.isNotBlank() }?.let { Triple(label, key, it) } + } + if (socials.isNotEmpty()) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("🔗 社媒账号", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(6.dp)) + socials.forEach { (label, _, value) -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text(label, style = MaterialTheme.typography.labelSmall, + modifier = Modifier.widthIn(min = 88.dp), maxLines = 1) + if (value.startsWith("http")) { + TextButton( + onClick = { runCatching { uriHandler.openUri(value) } }, + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), + ) { + Text(value, style = MaterialTheme.typography.bodySmall, + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 240.dp)) + } + } else { + Text(value, style = MaterialTheme.typography.bodySmall, + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f)) + } + } + } + } + } + } + // 编辑性格/社媒按钮(始终显示) + TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + Text("✎ 编辑性格/社媒", style = MaterialTheme.typography.labelMedium) + } Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Text("🧠 AI 印象", style = MaterialTheme.typography.titleSmall) @@ -457,6 +536,89 @@ private fun Pill(text: String, highlight: Boolean = false) { } } +/** 编辑结构化档案:MBTI / 八字 / 性格描述 / 社媒账号。 */ +@Composable +private fun EditProfileJsonDialog(p: Person, onDismiss: () -> Unit, onSave: (String?) -> Unit) { + val existing = remember(p.profileJson) { + p.profileJson?.takeIf { it.isNotBlank() }?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject() + } + var mbti by remember { mutableStateOf(existing.optString("mbti")) } + var bazi by remember { mutableStateOf(existing.optString("bazi")) } + var personality by remember { mutableStateOf(existing.optString("personality")) } + var blog by remember { mutableStateOf(existing.optString("blog")) } + var github by remember { mutableStateOf(existing.optString("github")) } + var linkedin by remember { mutableStateOf(existing.optString("linkedin")) } + var xHandle by remember { mutableStateOf(existing.optString("x")) } + var youtube by remember { mutableStateOf(existing.optString("youtube")) } + var xiaohongshu by remember { mutableStateOf(existing.optString("xiaohongshu")) } + var douyin by remember { mutableStateOf(existing.optString("douyin")) } + var weibo by remember { mutableStateOf(existing.optString("weibo")) } + var website by remember { mutableStateOf(existing.optString("website")) } + var notes by remember { mutableStateOf(existing.optString("notes")) } + + fun buildJson(): String? { + val obj = JSONObject() + if (mbti.isNotBlank()) obj.put("mbti", mbti.trim()) + if (bazi.isNotBlank()) obj.put("bazi", bazi.trim()) + if (personality.isNotBlank()) obj.put("personality", personality.trim()) + if (blog.isNotBlank()) obj.put("blog", blog.trim()) + if (github.isNotBlank()) obj.put("github", github.trim()) + if (linkedin.isNotBlank()) obj.put("linkedin", linkedin.trim()) + if (xHandle.isNotBlank()) obj.put("x", xHandle.trim()) + if (youtube.isNotBlank()) obj.put("youtube", youtube.trim()) + if (xiaohongshu.isNotBlank()) obj.put("xiaohongshu", xiaohongshu.trim()) + if (douyin.isNotBlank()) obj.put("douyin", douyin.trim()) + if (weibo.isNotBlank()) obj.put("weibo", weibo.trim()) + if (website.isNotBlank()) obj.put("website", website.trim()) + if (notes.isNotBlank()) obj.put("notes", notes.trim()) + return if (obj.length() == 0) null else obj.toString() + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("性格 & 社媒档案") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("性格分析", style = MaterialTheme.typography.labelLarge) + OutlinedTextField(value = mbti, onValueChange = { mbti = it }, singleLine = true, + label = { Text("MBTI(如 INTJ)") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = bazi, onValueChange = { bazi = it }, singleLine = true, + label = { Text("八字") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = personality, onValueChange = { personality = it }, + label = { Text("性格描述") }, minLines = 2, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(4.dp)) + Text("社媒账号", style = MaterialTheme.typography.labelLarge) + OutlinedTextField(value = blog, onValueChange = { blog = it }, singleLine = true, + label = { Text("Blog / 个人网站(完整 URL)") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = github, onValueChange = { github = it }, singleLine = true, + label = { Text("GitHub(用户名或 URL)") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = linkedin, onValueChange = { linkedin = it }, singleLine = true, + label = { Text("LinkedIn") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = xHandle, onValueChange = { xHandle = it }, singleLine = true, + label = { Text("X / Twitter(@handle 或 URL)") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = youtube, onValueChange = { youtube = it }, singleLine = true, + label = { Text("YouTube") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = xiaohongshu, onValueChange = { xiaohongshu = it }, singleLine = true, + label = { Text("小红书") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = douyin, onValueChange = { douyin = it }, singleLine = true, + label = { Text("抖音") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = weibo, onValueChange = { weibo = it }, singleLine = true, + label = { Text("微博") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = website, onValueChange = { website = it }, singleLine = true, + label = { Text("其他网站") }, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(4.dp)) + OutlinedTextField(value = notes, onValueChange = { notes = it }, + label = { Text("备注") }, minLines = 2, modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { TextButton(onClick = { onSave(buildJson()) }) { Text("保存") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + /** 编辑真名 + 简历(手填,本地保存)。 */ @Composable private fun EditProfileDialog(p: Person, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index c5570bc..feac349 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -257,6 +257,13 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } } + /** 结构化档案 JSON(MBTI/八字/性格分析/社媒账号);null = 清除。 */ + fun updatePersonProfileJson(personId: Long, json: String?) { + viewModelScope.launch(Dispatchers.IO) { + repo.persons.updateProfileJson(personId, json?.ifBlank { null }) + } + } + /** * 读手机系统通讯录 → 内存列表(不入库),供「手机通讯录」多选页展示。 * 一个联系人多号码只取第一条;无名字的跳过。调用方需先拿到 READ_CONTACTS。 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 index db2d363..d167c0b 100644 --- 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 @@ -2,6 +2,7 @@ package com.lemon.lifelog.chat import android.app.Application import android.content.Context +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -10,7 +11,6 @@ import androidx.lifecycle.viewModelScope import com.lemon.lifelog.data.ChatConversation import com.lemon.lifelog.data.ChatMessage import com.lemon.lifelog.data.Repository -import org.json.JSONArray import org.json.JSONObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -127,7 +127,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { streamingText = "" try { if (toolMode) { - agenticLoop(target, cid, history) + agenticLoopReAct(target, cid, history) } else { normalChat(text, target, cid, history.map { it.role to it.content }) } @@ -171,58 +171,77 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { } /** - * 工具模式 agentic loop: - * 1. 发带 tools 的请求 → 若 LLM 返回 tool_calls,本地执行 → 追加结果 → 再请求 - * 2. 直到 LLM 不再调工具(给出最终文字)或达到最大轮数 - * 3. 只把 user 消息和最终 assistant 文字存 Room(中间工具消息不持久化) + * ReAct 工具循环:用文本协议而非 native function calling,兼容所有网关/模型。 + * + * 协议:系统提示要求模型用 "TOOL_CALL: {json}" 行声明工具调用; + * 应用解析这些行 → 执行 → 把 TOOL_RESULT 以 user 消息追回 → 继续; + * 模型回复中不再出现 TOOL_CALL 时即为最终答案。 */ - private suspend fun agenticLoop( + private suspend fun agenticLoopReAct( target: ChatTarget, cid: Long, history: List, ) { - // 构建初始 messages(system prompt + 全部 Room 历史,已含刚存的 user 消息) - val messages: MutableList = mutableListOf() - messages.add(JSONObject().put("role", "system").put("content", TOOL_SYSTEM_PROMPT)) - history.forEach { m -> - messages.add(JSONObject().put("role", m.role).put("content", m.content)) + val messages = mutableListOf>() + messages.add("system" to REACT_SYSTEM_PROMPT) + // 历史里跳过之前的 system 消息(避免两个 system 冲突) + history.filter { it.role != "system" }.forEach { m -> + messages.add(m.role to m.content) } + val toolLog = StringBuilder() var finalText = "" var rounds = 0 toolStatus = "思考中…" while (rounds < MAX_TOOL_ROUNDS) { - val result = withContext(Dispatchers.IO) { - client.chatWithTools(target, messages, LifelogTools.SCHEMAS) - } - if (result.toolCalls.isNullOrEmpty()) { - finalText = result.text + Log.d(TAG, "reactLoop round=$rounds msgs=${messages.size}") + streamingText = "" + val response = client.stream(target, messages) { delta -> streamingText += delta } + Log.d(TAG, "reactLoop round=$rounds response=${response.take(120)}") + + val calls = parseReActCalls(response) + Log.d(TAG, "reactLoop round=$rounds calls=${calls.size}") + + if (calls.isEmpty()) { + // 没有 TOOL_CALL → 这就是最终答案,去掉可能残留的 TOOL_CALL 行 + finalText = response.lines() + .filter { !it.trimStart().startsWith("TOOL_CALL:") } + .joinToString("\n").trim() break } - // assistant 消息(含 tool_calls,API 要求原样回传) - val assistantMsg = JSONObject().put("role", "assistant").put("content", result.text.ifBlank { "" }) - result.rawToolCallsJson?.let { assistantMsg.put("tool_calls", it) } - messages.add(assistantMsg) - - // 逐个执行工具 - for (call in result.toolCalls) { - toolStatus = "⚙ ${call.name}" - streamingText = "调用工具:${call.name}\n" - val args = runCatching { JSONObject(call.arguments) }.getOrElse { JSONObject() } - val toolResult = LifelogTools.execute(repo, call.name, args) - streamingText += toolResult.take(200) - messages.add(JSONObject() - .put("role", "tool") - .put("tool_call_id", call.id) - .put("content", toolResult)) + + // 执行所有工具,收集结果 + val resultBlock = StringBuilder() + for ((name, args) in calls) { + toolStatus = "⚙ $name" + streamingText = "⚙ 调用:$name…" + Log.d(TAG, "exec react tool $name args=$args") + val result = withContext(Dispatchers.IO) { LifelogTools.execute(repo, name, args) } + Log.d(TAG, "react tool $name result=${result.take(120)}") + toolLog.appendLine("**[$name]** ${result.take(600)}") + resultBlock.appendLine("[$name 结果]") + resultBlock.appendLine(result.take(1000)) } + + // 把本轮对话追加进 messages(assistant 回复 + user 注入工具结果) + messages.add("assistant" to response) + messages.add("user" to "TOOL_RESULT:\n${resultBlock.toString().trim()}") rounds++ toolStatus = "整理回复中(第 $rounds 轮)…" streamingText = toolStatus } - if (finalText.isBlank()) finalText = "(未获得有效回复,请重试或检查服务商是否支持 function calling)" + // 组装最终消息:工具摘要 + LLM 文字 + if (finalText.isBlank() && toolLog.isEmpty()) { + finalText = "(未获得有效回复,请重试)" + } else if (finalText.isBlank()) { + finalText = toolLog.toString().trimEnd() + } else if (toolLog.isNotEmpty()) { + finalText = toolLog.toString().trimEnd() + "\n\n---\n\n" + finalText + } + + streamingText = finalText withContext(Dispatchers.IO) { repo.chats.insertMessage(ChatMessage( conversationId = cid, role = "assistant", @@ -233,16 +252,59 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { } } + /** 从模型回复文本里提取 "TOOL_CALL: {json}" 行 */ + private fun parseReActCalls(text: String): List> = + text.lines() + .filter { it.trimStart().startsWith("TOOL_CALL:") } + .mapNotNull { line -> + runCatching { + val json = JSONObject(line.substringAfter("TOOL_CALL:").trim()) + val name = json.getString("name") + val args = json.optJSONObject("args") ?: JSONObject() + name to args + }.getOrNull() + } + companion object { + private const val TAG = "ChatViewModel" private const val K_LIFELOG = "lifelog_mode" private const val K_TOOL = "tool_mode" private const val K_TARGET = "selected_target" private const val K_CONV = "current_conv" private const val MAX_TOOL_ROUNDS = 8 - private val TOOL_SYSTEM_PROMPT = """ - 你是用户的 lifelog AI 助手,可以访问用户 24/7 录音转写、会话纪要、联系人档案和相册。 - 当用户询问记忆、联系人或照片时,请主动调用相应工具获取真实数据后再回答。 - 多步骤问题可多次调用工具。最终回复用中文,简洁清晰。 + private val REACT_SYSTEM_PROMPT = """ + 你是用户的 lifelog AI 助手,可访问以下工具查询真实数据(禁止凭空编造): + + 【录音 & 纪要】 + ① search_transcripts — 搜录音转写(args: query, speaker可选, limit默认12) + ② get_summaries — 最近会话纪要(args: limit默认5) + + 【联系人 & 关系】 + ③ get_person — 查联系人档案(args: name) + ④ list_persons — 列 CRM 人物(args: managed_only默认true) + ⑤ get_relations — 某人的共现关系图(args: person_name) + ⑥ get_mentions — 录音中提到某人的片段(args: person_name, limit默认10) + + 【微信聊天 & 朋友圈】 + ⑦ search_wechat_chat — 搜微信私聊(args: query, person_name可选, limit默认10) + ⑧ search_group_chat — 搜微信群聊(args: query, limit默认10) + ⑨ get_moments — 某人朋友圈文字(args: person_name, keyword可选, limit默认20) + ⑩ search_moments — 全局搜朋友圈(args: query, limit默认10) + + 【相册 & 待跟进】 + ⑪ search_photos — 关键词搜相册(args: query, limit默认8) + ⑫ get_person_photos — 某人出现的照片(args: person_name, limit默认10) + ⑬ get_followups — 待跟进事项(args: 无) + + 需要查询时,输出(每个工具独占一行,严格 JSON): + TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} + + 示例: + TOOL_CALL: {"name": "search_transcripts", "args": {"query": "火星人", "limit": 5}} + TOOL_CALL: {"name": "get_person", "args": {"name": "张三"}} + + 收到 TOOL_RESULT 后继续分析,可再次调用工具。 + TOOL_CALL 行由系统截取执行,最终答案里不要重复输出。最终答案用中文,简洁清晰。 """.trimIndent() } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt index 9b052c4..021a624 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt @@ -45,11 +45,21 @@ object LifelogTools { )) put(schema( name = "get_person", - desc = "按姓名或备注查找联系人档案(基本资料、AI 印象、简历、关联声纹/人脸)", + desc = "按姓名或备注查找联系人档案(基本资料、AI 印象、简历、结构化档案含 MBTI/社媒等)", props = mapOf("name" to prop("string", "人物姓名或备注名")), required = listOf("name"), )) put(schema( + name = "update_person_profile", + desc = "更新联系人的结构化档案字段(MBTI、八字、性格描述、社媒账号等)", + props = mapOf( + "name" to prop("string", "人物姓名或备注名"), + "field" to prop("string", "要更新的字段:mbti / bazi / personality / blog / github / linkedin / x / youtube / xiaohongshu / douyin / weibo / website / notes"), + "value" to prop("string", "新值;空字符串表示清除该字段"), + ), + required = listOf("name", "field", "value"), + )) + put(schema( name = "list_persons", desc = "列出 CRM 中的联系人(默认只列我管理的人)", props = mapOf( @@ -78,6 +88,70 @@ object LifelogTools { props = emptyMap(), required = emptyList(), )) + put(schema( + name = "search_wechat_chat", + desc = "在微信私聊记录中按关键词搜索消息(不含群聊)", + props = mapOf( + "query" to prop("string", "搜索关键词"), + "person_name" to prop("string", "限定某人的对话,可选"), + "limit" to prop("integer", "返回条数,默认 10"), + ), + required = listOf("query"), + )) + put(schema( + name = "search_group_chat", + desc = "在微信群聊记录中按关键词搜索消息", + props = mapOf( + "query" to prop("string", "搜索关键词"), + "limit" to prop("integer", "返回条数,默认 10"), + ), + required = listOf("query"), + )) + put(schema( + name = "get_moments", + desc = "获取某人的微信朋友圈文字动态(按姓名查找,按时间倒序)", + props = mapOf( + "person_name" to prop("string", "人物姓名或昵称"), + "keyword" to prop("string", "可选:只返回含此关键词的朋友圈"), + "limit" to prop("integer", "返回条数,默认 20"), + ), + required = listOf("person_name"), + )) + put(schema( + name = "search_moments", + desc = "在所有联系人的朋友圈文字中全局关键词搜索", + props = mapOf( + "query" to prop("string", "搜索关键词"), + "limit" to prop("integer", "返回条数,默认 10"), + ), + required = listOf("query"), + )) + put(schema( + name = "get_person_photos", + desc = "获取某人出现的照片(人脸匹配),含 VL 场景描述(需要索引完成)", + props = mapOf( + "person_name" to prop("string", "人物姓名"), + "limit" to prop("integer", "返回张数,默认 10"), + ), + required = listOf("person_name"), + )) + put(schema( + name = "get_relations", + desc = "获取某人的共现关系(在录音中一起被提到的人,按共现权重排序)", + props = mapOf( + "person_name" to prop("string", "人物姓名"), + ), + required = listOf("person_name"), + )) + put(schema( + name = "get_mentions", + desc = "查询录音中提到某人的片段(区别于声纹:是「被说到」而非「谁说的」)", + props = mapOf( + "person_name" to prop("string", "人物姓名"), + "limit" to prop("integer", "返回条数,默认 10"), + ), + required = listOf("person_name"), + )) } } @@ -106,10 +180,18 @@ object LifelogTools { when (name) { "search_transcripts" -> searchTranscripts(repo, args) "get_person" -> getPerson(repo, args) + "update_person_profile" -> updatePersonProfile(repo, args) "list_persons" -> listPersons(repo, args) "get_summaries" -> getSummaries(repo, args) "search_photos" -> searchPhotos(repo, args) "get_followups" -> getFollowups(repo) + "search_wechat_chat" -> searchWechatChat(repo, args) + "search_group_chat" -> searchGroupChat(repo, args) + "get_moments" -> getMoments(repo, args) + "search_moments" -> searchMoments(repo, args) + "get_person_photos" -> getPersonPhotos(repo, args) + "get_relations" -> getRelations(repo, args) + "get_mentions" -> getMentions(repo, args) else -> "未知工具:$name" } } catch (t: Throwable) { @@ -154,6 +236,19 @@ object LifelogTools { if (p.wechatFriend == true) appendLine("微信好友:是") p.impression?.takeIf { it.isNotBlank() }?.let { appendLine("AI 印象:${it.take(400)}") } p.resume?.takeIf { it.isNotBlank() }?.let { appendLine("简历:${it.take(300)}") } + // 结构化档案 + p.profileJson?.takeIf { it.isNotBlank() }?.let { jsonStr -> + try { + val obj = JSONObject(jsonStr) + appendLine("── 结构化档案 ──") + obj.optString("mbti").takeIf { it.isNotBlank() }?.let { appendLine("MBTI:$it") } + obj.optString("bazi").takeIf { it.isNotBlank() }?.let { appendLine("八字:$it") } + obj.optString("personality").takeIf { it.isNotBlank() }?.let { appendLine("性格:${it.take(200)}") } + val socialKeys = listOf("github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "blog", "website") + socialKeys.forEach { k -> obj.optString(k).takeIf { it.isNotBlank() }?.let { appendLine("$k:$it") } } + obj.optString("notes").takeIf { it.isNotBlank() }?.let { appendLine("备注:${it.take(200)}") } + } catch (_: Exception) {} + } val tags = buildList { if (p.managed) add("CRM 管理中") if (p.voiceSpeakerId != null) add("有声纹") @@ -165,6 +260,23 @@ object LifelogTools { } } + private suspend fun updatePersonProfile(repo: Repository, args: JSONObject): String { + val q = args.optString("name", "").ifBlank { return "请提供人物姓名" } + val field = args.optString("field", "").ifBlank { return "请提供字段名(如 mbti, github, personality 等)" } + val value = args.optString("value", "") + val allowed = setOf("mbti", "bazi", "personality", "blog", "github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "website", "notes") + if (field !in allowed) return "不支持的字段「$field」。可用字段:${allowed.joinToString(", ")}" + val persons = repo.persons.all().filter { p -> + p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true || p.remark?.contains(q) == true + } + if (persons.isEmpty()) return "未找到与「$q」匹配的联系人。" + val p = persons.first() + val obj = try { JSONObject(p.profileJson ?: "{}") } catch (_: Exception) { JSONObject() } + if (value.isBlank()) obj.remove(field) else obj.put(field, value.trim()) + repo.persons.updateProfileJson(p.id, if (obj.length() == 0) null else obj.toString()) + return "已更新 ${p.name} 的档案字段 $field${if (value.isBlank()) "(已清除)" else " = ${value.trim()}"}" + } + private suspend fun listPersons(repo: Repository, args: JSONObject): String { val managedOnly = args.optBoolean("managed_only", true) val persons = repo.persons.all().let { if (managedOnly) it.filter { p -> p.managed } else it } @@ -232,4 +344,149 @@ object LifelogTools { } } } + + // ── 微信聊天 ────────────────────────────────────────────────────────────── + + private suspend fun searchWechatChat(repo: Repository, args: JSONObject): String { + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" } + val personName = args.optString("person_name", "").ifBlank { null } + val limit = args.optInt("limit", 10).coerceIn(1, 30) + + val allPersons = repo.persons.all() + val wxidToName = allPersons + .filter { it.contactRef?.startsWith("wechat:") == true } + .associate { it.contactRef!!.removePrefix("wechat:") to (it.realName ?: it.nickname ?: it.name) } + + var msgs = repo.wechatMessages.searchDirect(query, limit * 3) + if (personName != null) { + val wxid = allPersons.firstOrNull { p -> + p.name.contains(personName) || p.realName?.contains(personName) == true || + p.nickname?.contains(personName) == true + }?.contactRef?.removePrefix("wechat:") + if (wxid != null) msgs = msgs.filter { it.talker == wxid } + } + msgs = msgs.take(limit) + if (msgs.isEmpty()) return "未找到包含「$query」的微信私聊记录。" + return buildString { + appendLine("找到 ${msgs.size} 条微信私聊(关键词:「$query」):") + msgs.forEach { m -> + val name = wxidToName[m.talker] ?: m.talker + val dir = if (m.isSend) "我→$name" else "$name→我" + appendLine("[${TSL.format(Date(m.createTime))}] $dir:${m.content.take(200)}") + } + } + } + + private suspend fun searchGroupChat(repo: Repository, args: JSONObject): String { + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" } + val limit = args.optInt("limit", 10).coerceIn(1, 30) + val msgs = repo.wechatMessages.searchGroup(query, limit) + if (msgs.isEmpty()) return "未找到包含「$query」的微信群聊记录。" + return buildString { + appendLine("找到 ${msgs.size} 条群聊消息(关键词:「$query」):") + msgs.forEach { m -> + val dir = if (m.isSend) "我" else m.talker + appendLine("[${TSL.format(Date(m.createTime))}] 群(${m.talker}) $dir:${m.content.take(200)}") + } + } + } + + // ── 朋友圈 ─────────────────────────────────────────────────────────────── + + private suspend fun getMoments(repo: Repository, args: JSONObject): String { + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" } + val keyword = args.optString("keyword", "").ifBlank { null } + val limit = args.optInt("limit", 20).coerceIn(1, 50) + + val person = repo.persons.all().firstOrNull { p -> + p.name.contains(name) || p.realName?.contains(name) == true || + p.nickname?.contains(name) == true || p.remark?.contains(name) == true + } ?: return "未找到与「$name」匹配的联系人。" + val wxid = person.contactRef?.removePrefix("wechat:") + ?: return "「${person.name}」没有微信 ID,无法查询朋友圈。" + + val all = repo.moments.byUser(wxid, if (keyword != null) limit * 5 else limit) + val moments = if (keyword != null) all.filter { it.text.contains(keyword) }.take(limit) else all + if (moments.isEmpty()) return "「${person.name}」暂无朋友圈记录${if (keyword != null) "(含「$keyword」)" else ""}。" + return buildString { + appendLine("${person.name} 的朋友圈(${moments.size} 条):") + moments.forEach { m -> + appendLine("[${TS.format(Date(m.createTime))}] ${m.text.take(300)}") + appendLine() + } + } + } + + private suspend fun searchMoments(repo: Repository, args: JSONObject): String { + val query = args.optString("query", "").ifBlank { return "请提供搜索关键词" } + val limit = args.optInt("limit", 10).coerceIn(1, 30) + val moments = repo.moments.searchText(query, limit) + if (moments.isEmpty()) return "未找到包含「$query」的朋友圈内容。" + val wxidToName = repo.persons.all() + .filter { it.contactRef?.startsWith("wechat:") == true } + .associate { it.contactRef!!.removePrefix("wechat:") to (it.realName ?: it.nickname ?: it.name) } + return buildString { + appendLine("找到 ${moments.size} 条朋友圈(关键词:「$query」):") + moments.forEach { m -> + val who = wxidToName[m.userName] ?: m.userName + appendLine("[${TS.format(Date(m.createTime))}] $who:${m.text.take(300)}") + appendLine() + } + } + } + + // ── 相册 + 关系图 ───────────────────────────────────────────────────────── + + private suspend fun getPersonPhotos(repo: Repository, args: JSONObject): String { + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" } + val limit = args.optInt("limit", 10).coerceIn(1, 30) + val person = repo.persons.all().firstOrNull { p -> + p.name.contains(name) || p.realName?.contains(name) == true || + p.nickname?.contains(name) == true + } ?: return "未找到与「$name」匹配的联系人。" + val photos = repo.photos.observePhotosOfPerson(person.id).first().take(limit) + if (photos.isEmpty()) return "「${person.name}」暂无人脸匹配照片(需完成相册扫描)。" + return buildString { + appendLine("「${person.name}」出现的照片(${photos.size} 张):") + photos.forEach { p -> + val caption = p.caption?.take(120) ?: "(暂无 VL 描述)" + val state = when (p.scanState) { 2 -> "已描述" 1 -> "已识别" else -> "待处理" } + appendLine("[${TS.format(Date(p.takenTs))} $state] $caption") + } + } + } + + private suspend fun getRelations(repo: Repository, args: JSONObject): String { + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" } + val person = repo.persons.all().firstOrNull { p -> + p.name.contains(name) || p.realName?.contains(name) == true || + p.nickname?.contains(name) == true + } ?: return "未找到与「$name」匹配的联系人。" + val relations = repo.relations.observeRelatedTo(person.id).first() + if (relations.isEmpty()) return "「${person.name}」暂无共现关系(需先运行关系图构建)。" + return buildString { + appendLine("「${person.name}」的共现关系(${relations.size} 人):") + relations.take(15).forEach { r -> + appendLine("- ${r.name}(共现权重 ${r.weight})") + } + } + } + + private suspend fun getMentions(repo: Repository, args: JSONObject): String { + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" } + val limit = args.optInt("limit", 10).coerceIn(1, 30) + val person = repo.persons.all().firstOrNull { p -> + p.name.contains(name) || p.realName?.contains(name) == true || + p.nickname?.contains(name) == true + } ?: return "未找到与「$name」匹配的联系人。" + val segments = repo.mentions.observeMentionsOf(person.id, limit).first() + if (segments.isEmpty()) return "录音中未找到提到「${person.name}」的片段。" + return buildString { + appendLine("录音中提到「${person.name}」的片段(${segments.size} 条):") + segments.forEach { s -> + val who = s.speakerName ?: s.personName ?: "未知说话人" + appendLine("[${TSL.format(Date(s.startTs))}] $who:${s.text?.trim()?.take(200)}") + } + } + } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index f28f54e..df3d0af 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -28,7 +28,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase Topic::class, TopicMention::class, ], - version = 20, + version = 21, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -223,6 +223,13 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.9 结构化档案:persons.profileJson(MBTI/八字/性格/社媒,JSON blob,AI 可 query+update)。 */ + private val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `persons` ADD COLUMN `profileJson` TEXT") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -230,7 +237,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .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) + .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) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() 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 034fcf4..2f62851 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 @@ -251,6 +251,50 @@ interface SegmentDao { """, ) fun searchTimeline(query: String, limit: Int = 200): Flow> + + /** 按时间段查录音(search_by_time 工具用)。 */ + @Query( + """ + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, + t.text AS text, s.audioPath AS audioPath, + s.speakerId AS speakerId, s.speakerSim AS speakerSim, + sp.name AS speakerName, + p.id AS personId, p.name AS personName + FROM segments s + LEFT JOIN transcripts t ON t.segmentId = s.id + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId + WHERE s.startTs BETWEEN :start AND :end + ORDER BY s.startTs ASC + LIMIT :limit + """, + ) + suspend fun searchByTimeRange(start: Long, end: Long, limit: Int): List + + /** 某段附近 N 段(上下文查看):取 [ts-window, ts+window] 内的段,时间正序。 */ + @Query( + """ + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, + t.text AS text, s.audioPath AS audioPath, + s.speakerId AS speakerId, s.speakerSim AS speakerSim, + sp.name AS speakerName, + p.id AS personId, p.name AS personName + FROM segments s + LEFT JOIN transcripts t ON t.segmentId = s.id + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId + WHERE s.startTs BETWEEN :start AND :end + ORDER BY s.startTs ASC + LIMIT :limit + """, + ) + suspend fun contextAround(start: Long, end: Long, limit: Int): List + + /** 按 id 获取单段基本信息(用于定位 startTs 后再查上下文)。 */ + @Query("SELECT id, startTs, speakerId FROM segments WHERE id = :id") + suspend fun getSegMeta(id: Long): SegMeta? } @Dao @@ -598,6 +642,10 @@ interface PersonDao { @Query("UPDATE persons SET wechatFriend = :friend WHERE id = :id") suspend fun setWechatFriend(id: Long, friend: Boolean?) + /** 结构化档案 JSON(MBTI/八字/社媒,AI 可 query+update)。 */ + @Query("UPDATE persons SET profileJson = :json WHERE id = :id") + suspend fun updateProfileJson(id: Long, json: String?) + /** 合并人物:写回合并后的整条记录(按主键更新)。 */ @Update suspend fun update(p: Person) @@ -817,6 +865,10 @@ interface MomentDao { @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC LIMIT :n") suspend fun byUser(u: String, n: Int): List + + /** 全库关键词搜索朋友圈文字。 */ + @Query("SELECT * FROM moments WHERE text LIKE '%' || :q || '%' ORDER BY createTime DESC LIMIT :n") + suspend fun searchText(q: String, n: Int): List } @Dao @@ -837,6 +889,22 @@ interface WechatMessageDao { /** 某联系人(wxid)的聊天,时间正序。 */ @Query("SELECT * FROM wechat_messages WHERE talker = :talker ORDER BY createTime ASC") fun observeByTalker(talker: String): Flow> + + /** 关键词搜索私聊(排除群聊 talker 含 @chatroom)。 */ + @Query(""" + SELECT * FROM wechat_messages + WHERE content LIKE '%' || :q || '%' AND talker NOT LIKE '%@chatroom' + ORDER BY createTime DESC LIMIT :n + """) + suspend fun searchDirect(q: String, n: Int): List + + /** 关键词搜索群聊(talker 含 @chatroom)。 */ + @Query(""" + SELECT * FROM wechat_messages + WHERE content LIKE '%' || :q || '%' AND talker LIKE '%@chatroom' + ORDER BY createTime DESC LIMIT :n + """) + suspend fun searchGroup(q: String, n: Int): List } @Dao diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index 944939d..e6e8c46 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -115,6 +115,9 @@ data class Person( val resume: String? = null, // 简历 / 备注(职业、公司、关系等手填长文) /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ val impression: String? = null, + /** 结构化档案(JSON blob):MBTI、八字、性格分析、社媒账号等,AI 可 query+update。字段: + * mbti, bazi, personality, blog, github, linkedin, x, youtube, xiaohongshu, douyin, weibo, website, notes */ + val profileJson: String? = null, /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ val lastAccess: Long = 0, /** 是否纳入「我管理的人物」:微信导入默认 false(只是池子),勾选/标人脸/绑声纹/手动建 → true。 -- libgit2 0.21.4