Commit 8f47214f811c931bada0a56ce9240980eccbc4bc
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>
Showing
7 changed files
with
610 additions
and
44 deletions
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: () -> Unit, onEnrich: () -> Unit) { | @@ -425,6 +442,68 @@ private fun ResumeTab(p: Person, onEdit: () -> Unit, onEnrich: () -> 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。 |