Commit 136b916df71ff11bf587f664c956c12d7d9229f5
1 parent
1a7b4891
feat(lifelog/photolog): 档案页 collapsing 滚动 + 简历首 tab + 通讯录一键更新
人物档案页: - 整页改一个 LazyColumn:hero(头像/简介)+搜索框 当列表项随滚动滑走,TabRow stickyHeader 吸顶 → 内容区拿到整屏、能全屏滑(原来 hero 固定 top,内容只剩一小条) - 「简历」做成第一个 tab(真名/简历/AI印象),从 hero 的「关于卡」挪出 → hero 变矮 - 照片网格改 LazyColumn 内手动 3 列行(避免 lazy 嵌套同向滚动) 通讯录页: - 搜索框右边「🔄 更新」:批量更新所有有微信的管理人物——逐个 downloadWeChatCore(头像+朋友圈) + 全局重建 提及/关系/待办 + 催全相册识别。不碰录音;聊天暂不本地更新(EnMicroMsg 加密) - downloadWeChatImages 抽出 downloadWeChatCore 复用 并带入并行的端上 CRM(话题 TopicExtractor/TopicsScreen 等):与本功能在 Daos/迁移链耦合,整树可编译。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
9 changed files
with
543 additions
and
151 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| ... | ... | @@ -3,7 +3,9 @@ package com.lemon.lifelog |
| 3 | 3 | import androidx.compose.foundation.background |
| 4 | 4 | import androidx.compose.foundation.clickable |
| 5 | 5 | import androidx.compose.foundation.horizontalScroll |
| 6 | +import androidx.compose.foundation.ExperimentalFoundationApi | |
| 6 | 7 | import androidx.compose.foundation.rememberScrollState |
| 8 | +import androidx.compose.foundation.verticalScroll | |
| 7 | 9 | import androidx.compose.foundation.layout.Arrangement |
| 8 | 10 | import androidx.compose.foundation.layout.offset |
| 9 | 11 | import androidx.compose.foundation.layout.Box |
| ... | ... | @@ -73,7 +75,7 @@ import java.util.Locale |
| 73 | 75 | |
| 74 | 76 | private val PROFILE_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) |
| 75 | 77 | |
| 76 | -@OptIn(ExperimentalMaterial3Api::class) | |
| 78 | +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) | |
| 77 | 79 | @Composable |
| 78 | 80 | internal fun PersonProfileScreen( |
| 79 | 81 | vm: PhotologViewModel, |
| ... | ... | @@ -136,96 +138,84 @@ internal fun PersonProfileScreen( |
| 136 | 138 | IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } |
| 137 | 139 | }, |
| 138 | 140 | ) |
| 139 | - person?.let { p -> | |
| 140 | - ProfileHero( | |
| 141 | - p, speakers, | |
| 142 | - onBind = { bindVoice = true }, | |
| 143 | - onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, | |
| 144 | - onEnrich = { vm.enrichImpression(personId) }, | |
| 145 | - onEditProfile = { editProfile = true }, | |
| 146 | - onMerge = onRequestMerge, | |
| 147 | - onDownload = { | |
| 148 | - Toast.makeText(ctx, "下载中…(读微信本地缓存,首次需授予 root)", Toast.LENGTH_SHORT).show() | |
| 149 | - vm.downloadWeChatImages(personId) { _, msg -> | |
| 150 | - Toast.makeText(ctx, msg ?: "完成", Toast.LENGTH_LONG).show() | |
| 151 | - } | |
| 152 | - }, | |
| 153 | - ) | |
| 154 | - } | |
| 155 | - OutlinedTextField( | |
| 156 | - value = query, onValueChange = { query = it }, singleLine = true, | |
| 157 | - placeholder = { Text("在 TA 的记录里搜索…") }, | |
| 158 | - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | |
| 159 | - ) | |
| 160 | - ScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { | |
| 161 | - Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) | |
| 162 | - Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) | |
| 163 | - Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) | |
| 164 | - Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) | |
| 165 | - Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("被提到 ${mentioned.size}") }) | |
| 166 | - Tab(selected = tab == 5, onClick = { tab = 5 }, text = { Text("相关 ${related.size}") }) | |
| 167 | - } | |
| 168 | - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { | |
| 169 | - when (tab) { | |
| 170 | - 0 -> if (fPhotos.isEmpty()) EmptyHint(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") | |
| 171 | - else LazyVerticalGrid( | |
| 172 | - columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize().padding(4.dp), | |
| 173 | - verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 174 | - ) { | |
| 175 | - gridItems(fPhotos, key = { it.id }) { ph -> | |
| 176 | - AsyncImage( | |
| 177 | - model = photoModel(ph.source, ph.path), contentDescription = ph.caption, contentScale = ContentScale.Crop, | |
| 178 | - modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(6.dp)).clickable { onOpenPhoto(ph.id) }, | |
| 179 | - ) | |
| 180 | - } | |
| 141 | + // 整页一个 LazyColumn:hero + 搜索框随滚动一起滑走(当列表项),TabRow 吸顶,再接当前 tab 的内容。 | |
| 142 | + LazyColumn(modifier = Modifier.fillMaxSize()) { | |
| 143 | + item(key = "hero") { | |
| 144 | + person?.let { p -> | |
| 145 | + ProfileHero( | |
| 146 | + p, speakers, | |
| 147 | + onBind = { bindVoice = true }, | |
| 148 | + onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, | |
| 149 | + onEnrich = { vm.enrichImpression(personId) }, | |
| 150 | + onEditProfile = { editProfile = true }, | |
| 151 | + onMerge = onRequestMerge, | |
| 152 | + onDownload = { | |
| 153 | + Toast.makeText(ctx, "下载中…(读微信本地缓存,首次需授予 root)", Toast.LENGTH_SHORT).show() | |
| 154 | + vm.downloadWeChatImages(personId) { _, msg -> | |
| 155 | + Toast.makeText(ctx, msg ?: "完成", Toast.LENGTH_LONG).show() | |
| 156 | + } | |
| 157 | + }, | |
| 158 | + ) | |
| 181 | 159 | } |
| 182 | - 1 -> if (sid == null) EmptyHint("还没绑定声纹。点上方「绑定」选一个说话人,这里就会列出 TA 说过的录音。") | |
| 183 | - else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") | |
| 184 | - else LazyColumn(Modifier.fillMaxSize()) { | |
| 185 | - items(fAudio, key = { it.id }) { row -> | |
| 186 | - // 块级管线:turn 是块完整录音里的一段,按 audioStartMs+durationMs 切片才对得上文字。 | |
| 187 | - AudioRow(row, playing = playingId == row.id) { togglePlay(row) } | |
| 188 | - } | |
| 160 | + } | |
| 161 | + item(key = "search") { | |
| 162 | + OutlinedTextField( | |
| 163 | + value = query, onValueChange = { query = it }, singleLine = true, | |
| 164 | + placeholder = { Text("在 TA 的记录里搜索…") }, | |
| 165 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | |
| 166 | + ) | |
| 167 | + } | |
| 168 | + stickyHeader(key = "tabs") { | |
| 169 | + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { | |
| 170 | + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("简历") }) | |
| 171 | + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("照片 ${photos.size}") }) | |
| 172 | + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("语音 ${audio.size}") }) | |
| 173 | + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("聊天 ${messages.size}") }) | |
| 174 | + Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("朋友圈 ${moments.size}") }) | |
| 175 | + Tab(selected = tab == 5, onClick = { tab = 5 }, text = { Text("被提到 ${mentioned.size}") }) | |
| 176 | + Tab(selected = tab == 6, onClick = { tab = 6 }, text = { Text("相关 ${related.size}") }) | |
| 189 | 177 | } |
| 190 | - 2 -> if (wxid == null) EmptyHint("非微信联系人。") | |
| 191 | - else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") | |
| 192 | - else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } | |
| 193 | - 3 -> if (wxid == null) EmptyHint("非微信联系人。") | |
| 194 | - else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") | |
| 195 | - else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo, wxid.orEmpty()) } } | |
| 196 | - // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 | |
| 197 | - 4 -> Column(Modifier.fillMaxSize()) { | |
| 198 | - Row( | |
| 199 | - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), | |
| 200 | - horizontalArrangement = Arrangement.SpaceBetween, | |
| 201 | - verticalAlignment = Alignment.CenterVertically, | |
| 202 | - ) { | |
| 203 | - Text("录音里提到 TA 的段", style = MaterialTheme.typography.labelMedium) | |
| 204 | - TextButton(onClick = { vm.rescanMentions() }, enabled = !mentionScanning) { | |
| 205 | - Text(if (mentionScanning) "扫描中…" else "重新扫描") | |
| 178 | + } | |
| 179 | + when (tab) { | |
| 180 | + 0 -> item(key = "resume") { person?.let { p -> ResumeTab(p, onEdit = { editProfile = true }, onEnrich = { vm.enrichImpression(personId) }) } } | |
| 181 | + 1 -> if (fPhotos.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") } | |
| 182 | + 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)) { | |
| 184 | + rowPhotos.forEach { ph -> | |
| 185 | + AsyncImage( | |
| 186 | + model = photoModel(ph.source, ph.path), contentDescription = ph.caption, contentScale = ContentScale.Crop, | |
| 187 | + modifier = Modifier.weight(1f).aspectRatio(1f).clip(RoundedCornerShape(6.dp)).clickable { onOpenPhoto(ph.id) }, | |
| 188 | + ) | |
| 206 | 189 | } |
| 190 | + repeat(3 - rowPhotos.size) { Spacer(Modifier.weight(1f)) } | |
| 207 | 191 | } |
| 208 | - if (fMentioned.isEmpty()) { | |
| 209 | - EmptyHint( | |
| 210 | - if (q.isBlank()) "还没扫到提及。点「重新扫描」对历史录音反查;新录音会自动入库。\n(需 TA 在「我管理的人物」里,且名字/备注/昵称能在转写文本中被认出)" | |
| 211 | - else "没有匹配的提及。", | |
| 212 | - ) | |
| 213 | - } else { | |
| 214 | - LazyColumn(Modifier.fillMaxSize()) { | |
| 215 | - items(fMentioned, key = { it.id }) { row -> | |
| 216 | - AudioRow(row, playing = playingId == row.id) { togglePlay(row) } | |
| 192 | + } | |
| 193 | + 2 -> if (sid == null) item { ProfileEmpty("还没绑定声纹。点上方「🎤 录一段 / 选声纹」绑定,这里就会列出 TA 说过的录音。") } | |
| 194 | + else if (fAudio.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") } | |
| 195 | + else items(fAudio, key = { it.id }) { row -> AudioRow(row, playing = playingId == row.id) { togglePlay(row) } } | |
| 196 | + 3 -> if (wxid == null) item { ProfileEmpty("非微信联系人。") } | |
| 197 | + else if (fMsgs.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") } | |
| 198 | + else items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } | |
| 199 | + 4 -> if (wxid == null) item { ProfileEmpty("非微信联系人。") } | |
| 200 | + else if (fMoments.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") } | |
| 201 | + else items(fMoments, key = { it.id }) { mo -> MomentRow(mo, wxid.orEmpty()) } | |
| 202 | + 5 -> { | |
| 203 | + item(key = "mhdr") { | |
| 204 | + Row( | |
| 205 | + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), | |
| 206 | + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, | |
| 207 | + ) { | |
| 208 | + Text("录音里提到 TA 的段", style = MaterialTheme.typography.labelMedium) | |
| 209 | + TextButton(onClick = { vm.rescanMentions() }, enabled = !mentionScanning) { | |
| 210 | + Text(if (mentionScanning) "扫描中…" else "重新扫描") | |
| 217 | 211 | } |
| 218 | 212 | } |
| 219 | 213 | } |
| 214 | + if (fMentioned.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "还没扫到提及。点「重新扫描」对历史录音反查;新录音会自动入库。\n(需 TA 在「我管理的人物」里,且名字/备注/昵称能在转写中被认出)" else "没有匹配的提及。") } | |
| 215 | + else items(fMentioned, key = { it.id }) { row -> AudioRow(row, playing = playingId == row.id) { togglePlay(row) } } | |
| 220 | 216 | } |
| 221 | - // 相关:同会话共现(在场/被提到)的人,按共现次数排。 | |
| 222 | - else -> if (related.isEmpty()) EmptyHint( | |
| 223 | - "还没有相关的人。共现关系来自录音会话:同一段时间里同时出现/被提到的人会连起来。\n到「被提到」tab 点「重新扫描」可重建。", | |
| 224 | - ) else LazyColumn(Modifier.fillMaxSize()) { | |
| 225 | - items(related, key = { it.id }) { rp -> | |
| 226 | - RelatedRow(rp) { onOpenPerson(rp.id) } | |
| 227 | - } | |
| 228 | - } | |
| 217 | + else -> if (related.isEmpty()) item { ProfileEmpty("还没有相关的人。共现关系来自录音会话:同一段时间里同时出现/被提到的人会连起来。到「被提到」点「重新扫描」可重建。") } | |
| 218 | + else items(related, key = { it.id }) { rp -> RelatedRow(rp) { onOpenPerson(rp.id) } } | |
| 229 | 219 | } |
| 230 | 220 | } |
| 231 | 221 | } |
| ... | ... | @@ -403,31 +393,49 @@ private fun ProfileHero( |
| 403 | 393 | TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) } |
| 404 | 394 | } |
| 405 | 395 | |
| 406 | - // ---- 「关于」卡:简历 + AI 印象 ---- | |
| 396 | + // 「关于/简历」已挪到下方「简历」tab(hero 保持紧凑,给 tab 内容腾出整屏)。 | |
| 407 | 397 | Spacer(Modifier.height(6.dp)) |
| 408 | - Surface( | |
| 409 | - color = MaterialTheme.colorScheme.surfaceVariant, | |
| 410 | - shape = RoundedCornerShape(12.dp), | |
| 411 | - modifier = Modifier.fillMaxWidth(), | |
| 412 | - ) { | |
| 413 | - Column(modifier = Modifier.padding(12.dp)) { | |
| 414 | - Text("关于", style = MaterialTheme.typography.titleSmall) | |
| 415 | - Spacer(Modifier.height(4.dp)) | |
| 416 | - val resume = p.resume?.takeIf { it.isNotBlank() } | |
| 417 | - if (resume != null) { | |
| 418 | - Text(resume, style = MaterialTheme.typography.bodyMedium, maxLines = 6, overflow = TextOverflow.Ellipsis) | |
| 419 | - } else { | |
| 420 | - Text("还没有简历。点「✎ 编辑」补充职业 / 公司 / 关系等。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) | |
| 421 | - } | |
| 422 | - p.impression?.takeIf { it.isNotBlank() }?.let { | |
| 423 | - Spacer(Modifier.height(8.dp)) | |
| 424 | - Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth()) { | |
| 425 | - Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) | |
| 426 | - } | |
| 427 | - } | |
| 398 | + } | |
| 399 | + } | |
| 400 | +} | |
| 401 | + | |
| 402 | +/** 「简历」tab:真名 + 简历正文 + AI 印象(只读展示;编辑/生成走按钮)。外层 LazyColumn 负责滚动。 */ | |
| 403 | +@Composable | |
| 404 | +private fun ResumeTab(p: Person, onEdit: () -> Unit, onEnrich: () -> Unit) { | |
| 405 | + Column( | |
| 406 | + modifier = Modifier.fillMaxWidth().padding(16.dp), | |
| 407 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 408 | + ) { | |
| 409 | + p.realName?.takeIf { it.isNotBlank() }?.let { | |
| 410 | + Text("真名:$it", style = MaterialTheme.typography.titleSmall) | |
| 411 | + } | |
| 412 | + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { | |
| 413 | + Column(Modifier.padding(12.dp)) { | |
| 414 | + Text("简历", style = MaterialTheme.typography.titleSmall) | |
| 415 | + Spacer(Modifier.height(4.dp)) | |
| 416 | + val resume = p.resume?.takeIf { it.isNotBlank() } | |
| 417 | + if (resume != null) { | |
| 418 | + Text(resume, style = MaterialTheme.typography.bodyMedium) | |
| 419 | + } else { | |
| 420 | + Text("还没有简历。点「✎ 编辑」补充职业 / 公司 / 关系等。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) | |
| 421 | + } | |
| 422 | + Spacer(Modifier.height(6.dp)) | |
| 423 | + TextButton(onClick = onEdit, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { | |
| 424 | + Text("✎ 编辑真名/简历", style = MaterialTheme.typography.labelMedium) | |
| 425 | + } | |
| 426 | + } | |
| 427 | + } | |
| 428 | + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { | |
| 429 | + Column(Modifier.padding(12.dp)) { | |
| 430 | + Text("🧠 AI 印象", style = MaterialTheme.typography.titleSmall) | |
| 431 | + Spacer(Modifier.height(4.dp)) | |
| 432 | + val imp = p.impression?.takeIf { it.isNotBlank() } | |
| 433 | + Text(imp ?: "还没生成。点下方按钮,本地 4B 看 TA 的朋友圈/聊天画像。", style = MaterialTheme.typography.bodyMedium) | |
| 434 | + Spacer(Modifier.height(6.dp)) | |
| 435 | + TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { | |
| 436 | + Text(if (imp == null) "🧠 生成 AI 印象" else "🧠 重新生成", style = MaterialTheme.typography.labelMedium) | |
| 428 | 437 | } |
| 429 | 438 | } |
| 430 | - Spacer(Modifier.height(6.dp)) | |
| 431 | 439 | } |
| 432 | 440 | } |
| 433 | 441 | } |
| ... | ... | @@ -482,6 +490,14 @@ private fun EmptyHint(text: String) { |
| 482 | 490 | } |
| 483 | 491 | } |
| 484 | 492 | |
| 493 | +/** 空状态(用作 LazyColumn item:固定高度居中,避免 fillMaxSize 在 lazy item 里塌成 0)。 */ | |
| 494 | +@Composable | |
| 495 | +private fun ProfileEmpty(text: String) { | |
| 496 | + Box(Modifier.fillMaxWidth().height(260.dp).padding(24.dp), contentAlignment = Alignment.Center) { | |
| 497 | + Text(text, style = MaterialTheme.typography.bodyMedium) | |
| 498 | + } | |
| 499 | +} | |
| 500 | + | |
| 485 | 501 | @Composable |
| 486 | 502 | private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, onToggle: () -> Unit) { |
| 487 | 503 | Row( | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -100,6 +100,7 @@ internal fun ContactsTab(repo: Repository) { |
| 100 | 100 | var showPhonePicker by remember { mutableStateOf(false) } |
| 101 | 101 | var showCommunities by remember { mutableStateOf(false) } |
| 102 | 102 | var showFollowups by remember { mutableStateOf(false) } |
| 103 | + var showTopics by remember { mutableStateOf(false) } | |
| 103 | 104 | var mergeForId by remember { mutableStateOf<Long?>(null) } |
| 104 | 105 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 105 | 106 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| ... | ... | @@ -122,6 +123,7 @@ internal fun ContactsTab(repo: Repository) { |
| 122 | 123 | onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, |
| 123 | 124 | ) |
| 124 | 125 | showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) |
| 126 | + showTopics -> TopicsScreen(vm = vm, onBack = { showTopics = false }) | |
| 125 | 127 | showPicker -> WeChatPickerScreen( |
| 126 | 128 | vm = vm, |
| 127 | 129 | onBack = { showPicker = false }, |
| ... | ... | @@ -133,6 +135,7 @@ internal fun ContactsTab(repo: Repository) { |
| 133 | 135 | onOpenPicker = { showPicker = true }, |
| 134 | 136 | onOpenCommunities = { showCommunities = true }, |
| 135 | 137 | onOpenFollowups = { showFollowups = true }, |
| 138 | + onOpenTopics = { showTopics = true }, | |
| 136 | 139 | ) |
| 137 | 140 | } |
| 138 | 141 | detailPhotoId?.let { id -> |
| ... | ... | @@ -643,7 +646,7 @@ private fun TagFaceDialog( |
| 643 | 646 | /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ |
| 644 | 647 | @OptIn(ExperimentalFoundationApi::class) |
| 645 | 648 | @Composable |
| 646 | -private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit) { | |
| 649 | +private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit) { | |
| 647 | 650 | var query by remember { mutableStateOf("") } |
| 648 | 651 | |
| 649 | 652 | val sections = remember(persons, query) { |
| ... | ... | @@ -662,12 +665,27 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 662 | 665 | else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12) |
| 663 | 666 | } |
| 664 | 667 | |
| 668 | + val ctx = LocalContext.current | |
| 669 | + val updating by vm.updating.collectAsState() | |
| 665 | 670 | Column(modifier = Modifier.fillMaxSize()) { |
| 666 | - OutlinedTextField( | |
| 667 | - value = query, onValueChange = { query = it }, | |
| 668 | - placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true, | |
| 671 | + Row( | |
| 669 | 672 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), |
| 670 | - ) | |
| 673 | + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 674 | + ) { | |
| 675 | + OutlinedTextField( | |
| 676 | + value = query, onValueChange = { query = it }, | |
| 677 | + placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true, | |
| 678 | + modifier = Modifier.weight(1f), | |
| 679 | + ) | |
| 680 | + // 🔄 一键更新所有有微信的管理人物:头像+朋友圈 + 关系/待办/提及 + 相册识别(不碰录音)。 | |
| 681 | + Button( | |
| 682 | + enabled = !updating, | |
| 683 | + onClick = { | |
| 684 | + android.widget.Toast.makeText(ctx, "更新中…(逐个读微信本地缓存,人多会慢)", android.widget.Toast.LENGTH_SHORT).show() | |
| 685 | + vm.updateAllManaged { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() } | |
| 686 | + }, | |
| 687 | + ) { Text(if (updating) "更新中" else "🔄 更新") } | |
| 688 | + } | |
| 671 | 689 | Row( |
| 672 | 690 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), |
| 673 | 691 | verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), |
| ... | ... | @@ -675,6 +693,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 675 | 693 | Button(onClick = onOpenPicker) { Text("📇 通讯录") } |
| 676 | 694 | OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } |
| 677 | 695 | OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } |
| 696 | + OutlinedButton(onClick = onOpenTopics) { Text("🏷 话题") } | |
| 678 | 697 | } |
| 679 | 698 | HorizontalDivider() |
| 680 | 699 | if (persons.isEmpty()) { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| ... | ... | @@ -186,6 +186,18 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 186 | 186 | fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } |
| 187 | 187 | fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) } |
| 188 | 188 | |
| 189 | + /** 话题实体:本地 4B 从最近录音抽 组织/项目/事件(需 llama-swap 在跑)。 */ | |
| 190 | + private val _topicScanning = MutableStateFlow(false) | |
| 191 | + val topicScanning: StateFlow<Boolean> = _topicScanning | |
| 192 | + fun scanTopics() { | |
| 193 | + if (_topicScanning.value) return | |
| 194 | + _topicScanning.value = true | |
| 195 | + viewModelScope.launch { | |
| 196 | + runCatching { com.lemon.lifelog.crm.TopicExtractor.scan(getApplication(), repo) } | |
| 197 | + _topicScanning.value = false | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 189 | 201 | fun clearFace(faceId: Long) { |
| 190 | 202 | viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } |
| 191 | 203 | } |
| ... | ... | @@ -466,52 +478,91 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 466 | 478 | * 回调 (头像张数, 人读消息)。 |
| 467 | 479 | */ |
| 468 | 480 | fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { |
| 469 | - val app = getApplication<Application>() | |
| 470 | 481 | viewModelScope.launch { |
| 471 | - val (added, msg) = withContext(Dispatchers.IO) { | |
| 472 | - val p = repo.persons.get(personId) ?: return@withContext 0 to "人物不存在" | |
| 473 | - val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } | |
| 474 | - ?: return@withContext 0 to "不是微信联系人,无法下载" | |
| 475 | - val photosDir = File(app.filesDir, "photos").apply { mkdirs() } | |
| 482 | + val (avatarN, momentN, msg) = withContext(Dispatchers.IO) { downloadWeChatCore(personId) } | |
| 483 | + onResult(avatarN, msg) | |
| 484 | + } | |
| 485 | + } | |
| 476 | 486 | |
| 477 | - // 清掉旧版误倒进相册的朋友圈图(path 形如 photos/moment_<wxid>_*.jpg)+ 其归属脸。 | |
| 478 | - runCatching { | |
| 479 | - for (id in repo.photos.idsByPathLike("%/moment_${wxid}_%")) { | |
| 480 | - repo.faces.deleteByPhoto(id); repo.photos.delete(id) | |
| 481 | - } | |
| 482 | - } | |
| 487 | + /** 单人微信下载核心(**IO 线程调**):头像→照片(归属脸) + 朋友圈按条存。返回 (头像数, 朋友圈条数, 人读消息)。 */ | |
| 488 | + private suspend fun downloadWeChatCore(personId: Long): Triple<Int, Int, String> { | |
| 489 | + val app = getApplication<Application>() | |
| 490 | + val p = repo.persons.get(personId) ?: return Triple(0, 0, "人物不存在") | |
| 491 | + val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } | |
| 492 | + ?: return Triple(0, 0, "不是微信联系人") | |
| 493 | + val photosDir = File(app.filesDir, "photos").apply { mkdirs() } | |
| 494 | + // 清掉旧版误倒进相册的朋友圈图(path 形如 photos/moment_<wxid>_*.jpg)+ 其归属脸。 | |
| 495 | + runCatching { | |
| 496 | + for (id in repo.photos.idsByPathLike("%/moment_${wxid}_%")) { | |
| 497 | + repo.faces.deleteByPhoto(id); repo.photos.delete(id) | |
| 498 | + } | |
| 499 | + } | |
| 500 | + // 1) 头像 → 照片(归属脸)。 | |
| 501 | + var avatarN = 0 | |
| 502 | + val avatar = File(app.filesDir, "wx_enroll/avatars/$wxid.jpg") | |
| 503 | + if (avatar.exists()) { | |
| 504 | + val dst = File(photosDir, "wxavatar_$wxid.jpg") | |
| 505 | + if (repo.photos.getByPath(dst.absolutePath) == null) { | |
| 506 | + runCatching { avatar.copyTo(dst, overwrite = true); importOwnedPhoto(dst, personId, p.createdAt); avatarN++ } | |
| 507 | + } | |
| 508 | + } | |
| 509 | + // 2) 朋友圈:图按条存到 moments/<wxid>/,文字进 moments 表(不进照片相册)。 | |
| 510 | + val momentsDir = File(app.filesDir, "moments/$wxid").apply { deleteRecursively(); mkdirs() } | |
| 511 | + val r = WeChatSnsImporter.importMomentsFor(app, wxid, momentsDir) | |
| 512 | + var momentN = 0 | |
| 513 | + var imgN = 0 | |
| 514 | + if (r.moments.isNotEmpty()) { | |
| 515 | + repo.moments.clearUser(wxid) | |
| 516 | + repo.moments.insertAll(r.moments.map { Moment(userName = wxid, text = it.text, createTime = it.createTimeMs) }) | |
| 517 | + momentN = r.moments.size | |
| 518 | + imgN = r.moments.sumOf { it.images.size } | |
| 519 | + } | |
| 520 | + if (avatarN > 0 || momentN > 0) { | |
| 521 | + repo.persons.setManaged(personId, true) | |
| 522 | + repo.persons.touch(personId, System.currentTimeMillis()) | |
| 523 | + if (avatarN > 0) PhotoScheduler.runNow(app) | |
| 524 | + } | |
| 525 | + val ok = avatarN > 0 || momentN > 0 | |
| 526 | + return Triple(avatarN, momentN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容")) | |
| 527 | + } | |
| 483 | 528 | |
| 484 | - // 1) 头像 → 照片(归属脸)。 | |
| 485 | - var avatarN = 0 | |
| 486 | - val avatar = File(app.filesDir, "wx_enroll/avatars/$wxid.jpg") | |
| 487 | - if (avatar.exists()) { | |
| 488 | - val dst = File(photosDir, "wxavatar_$wxid.jpg") | |
| 489 | - if (repo.photos.getByPath(dst.absolutePath) == null) { | |
| 490 | - runCatching { avatar.copyTo(dst, overwrite = true); importOwnedPhoto(dst, personId, p.createdAt); avatarN++ } | |
| 529 | + /** | |
| 530 | + * 通讯录「🔄 更新」:批量更新**所有有微信的管理人物**。 | |
| 531 | + * - 微信:逐个 downloadWeChatCore(头像+朋友圈图+文字); | |
| 532 | + * - 派生分析(全局一次):提及反查(MentionScanner) + 人↔人共现(RelationBuilder) + 待办(FollowupExtractor); | |
| 533 | + * - 图片识别:催一遍全相册扫描 + VL。 | |
| 534 | + * 不碰录音(24/7 后台自动转写/认人)。聊天暂不本地更新(EnMicroMsg 加密,仍靠 Mac enroll)。 | |
| 535 | + */ | |
| 536 | + private val _updating = MutableStateFlow(false) | |
| 537 | + val updating: StateFlow<Boolean> = _updating | |
| 538 | + fun updateAllManaged(onDone: (String) -> Unit) { | |
| 539 | + if (_updating.value) return | |
| 540 | + _updating.value = true | |
| 541 | + val app = getApplication<Application>() | |
| 542 | + viewModelScope.launch { | |
| 543 | + val summary = withContext(Dispatchers.IO) { | |
| 544 | + val targets = repo.persons.all().filter { it.managed && it.contactRef?.startsWith("wechat:") == true } | |
| 545 | + var people = 0 | |
| 546 | + var moments = 0 | |
| 547 | + for (p in targets) { | |
| 548 | + runCatching { | |
| 549 | + val (a, m, _) = downloadWeChatCore(p.id) | |
| 550 | + if (a > 0 || m > 0) people++ | |
| 551 | + moments += m | |
| 491 | 552 | } |
| 492 | 553 | } |
| 493 | - | |
| 494 | - // 2) 朋友圈:图按条存到 moments/<wxid>/,文字进 moments 表(不进照片相册)。 | |
| 495 | - val momentsDir = File(app.filesDir, "moments/$wxid").apply { deleteRecursively(); mkdirs() } | |
| 496 | - val r = WeChatSnsImporter.importMomentsFor(app, wxid, momentsDir) | |
| 497 | - var momentN = 0 | |
| 498 | - var imgN = 0 | |
| 499 | - if (r.moments.isNotEmpty()) { | |
| 500 | - repo.moments.clearUser(wxid) | |
| 501 | - repo.moments.insertAll(r.moments.map { Moment(userName = wxid, text = it.text, createTime = it.createTimeMs) }) | |
| 502 | - momentN = r.moments.size | |
| 503 | - imgN = r.moments.sumOf { it.images.size } | |
| 504 | - } | |
| 505 | - | |
| 506 | - if (avatarN > 0 || momentN > 0) { | |
| 507 | - repo.persons.setManaged(personId, true) | |
| 508 | - repo.persons.touch(personId, System.currentTimeMillis()) | |
| 509 | - if (avatarN > 0) PhotoScheduler.runNow(app) // 让后台对头像跑人脸 | |
| 554 | + // 派生分析(全局各一次) | |
| 555 | + runCatching { | |
| 556 | + com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) | |
| 557 | + com.lemon.lifelog.crm.RelationBuilder.rebuild(repo) | |
| 510 | 558 | } |
| 511 | - val ok = avatarN > 0 || momentN > 0 | |
| 512 | - Pair(avatarN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容")) | |
| 559 | + runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(app, repo) } | |
| 560 | + "已更新 $people 人 · $moments 条朋友圈;关系/待办/提及已重建" | |
| 513 | 561 | } |
| 514 | - onResult(added, msg) | |
| 562 | + // 图片识别:催全相册扫描 + VL(enqueue,不阻塞)。 | |
| 563 | + scanGallery(); runVlNow() | |
| 564 | + _updating.value = false | |
| 565 | + onDone("$summary;相册识别已催") | |
| 515 | 566 | } |
| 516 | 567 | } |
| 517 | 568 | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TopicsScreen.kt
0 → 100644
| 1 | +package com.lemon.lifelog | |
| 2 | + | |
| 3 | +import androidx.compose.foundation.clickable | |
| 4 | +import androidx.compose.foundation.layout.Arrangement | |
| 5 | +import androidx.compose.foundation.layout.Box | |
| 6 | +import androidx.compose.foundation.layout.Column | |
| 7 | +import androidx.compose.foundation.layout.Row | |
| 8 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 9 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 10 | +import androidx.compose.foundation.layout.padding | |
| 11 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 12 | +import androidx.compose.foundation.lazy.items | |
| 13 | +import androidx.compose.material.icons.Icons | |
| 14 | +import androidx.compose.material.icons.automirrored.filled.ArrowBack | |
| 15 | +import androidx.compose.material3.ExperimentalMaterial3Api | |
| 16 | +import androidx.compose.material3.HorizontalDivider | |
| 17 | +import androidx.compose.material3.Icon | |
| 18 | +import androidx.compose.material3.IconButton | |
| 19 | +import androidx.compose.material3.MaterialTheme | |
| 20 | +import androidx.compose.material3.Text | |
| 21 | +import androidx.compose.material3.TextButton | |
| 22 | +import androidx.compose.material3.TopAppBar | |
| 23 | +import androidx.compose.runtime.Composable | |
| 24 | +import androidx.compose.runtime.collectAsState | |
| 25 | +import androidx.compose.runtime.getValue | |
| 26 | +import androidx.compose.runtime.mutableStateOf | |
| 27 | +import androidx.compose.runtime.remember | |
| 28 | +import androidx.compose.runtime.setValue | |
| 29 | +import androidx.compose.ui.Alignment | |
| 30 | +import androidx.compose.ui.Modifier | |
| 31 | +import androidx.compose.ui.text.style.TextOverflow | |
| 32 | +import androidx.compose.ui.unit.dp | |
| 33 | +import com.lemon.lifelog.data.TopicRow | |
| 34 | +import java.text.SimpleDateFormat | |
| 35 | +import java.util.Date | |
| 36 | +import java.util.Locale | |
| 37 | + | |
| 38 | +private val TOPIC_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) | |
| 39 | +private fun kindLabel(k: String) = when (k) { "org" -> "公司 / 组织"; "project" -> "项目 / 产品"; "event" -> "事件 / 活动"; else -> k } | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * 话题(组织/项目/事件):本地 4B 从录音抽出,按类别分组。点话题看在哪些录音里聊到。 | |
| 43 | + */ | |
| 44 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 45 | +@Composable | |
| 46 | +internal fun TopicsScreen(vm: PhotologViewModel, onBack: () -> Unit) { | |
| 47 | + val topics by vm.repo.topics.observeAll().collectAsState(initial = emptyList()) | |
| 48 | + val scanning by vm.topicScanning.collectAsState() | |
| 49 | + var selected by remember { mutableStateOf<TopicRow?>(null) } | |
| 50 | + | |
| 51 | + val sel = selected | |
| 52 | + if (sel != null) { | |
| 53 | + TopicDetail(vm, sel, onBack = { selected = null }) | |
| 54 | + return | |
| 55 | + } | |
| 56 | + | |
| 57 | + Column(Modifier.fillMaxSize()) { | |
| 58 | + TopAppBar( | |
| 59 | + title = { Text("话题") }, | |
| 60 | + navigationIcon = { | |
| 61 | + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } | |
| 62 | + }, | |
| 63 | + actions = { | |
| 64 | + TextButton(onClick = { vm.scanTopics() }, enabled = !scanning) { | |
| 65 | + Text(if (scanning) "扫描中…" else "扫描") | |
| 66 | + } | |
| 67 | + }, | |
| 68 | + ) | |
| 69 | + if (topics.isEmpty()) { | |
| 70 | + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 71 | + Text( | |
| 72 | + if (scanning) "本地 4B 正在从最近录音里抽 组织/项目/事件…" | |
| 73 | + else "还没有话题。点右上「扫描」让本地 4B 从最近录音里抽 组织/项目/事件。\n(需 llama-swap 在跑)", | |
| 74 | + style = MaterialTheme.typography.bodyMedium, | |
| 75 | + ) | |
| 76 | + } | |
| 77 | + } else { | |
| 78 | + // 按 kind 分组:org / project / event | |
| 79 | + val grouped = topics.groupBy { it.kind } | |
| 80 | + LazyColumn(Modifier.fillMaxSize()) { | |
| 81 | + listOf("org", "project", "event").forEach { kind -> | |
| 82 | + val list = grouped[kind].orEmpty() | |
| 83 | + if (list.isNotEmpty()) { | |
| 84 | + item(key = "h_$kind") { | |
| 85 | + Text( | |
| 86 | + "${kindLabel(kind)}(${list.size})", | |
| 87 | + style = MaterialTheme.typography.titleSmall, | |
| 88 | + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), | |
| 89 | + ) | |
| 90 | + } | |
| 91 | + items(list, key = { it.id }) { t -> | |
| 92 | + Row( | |
| 93 | + Modifier.fillMaxWidth().clickable { selected = t }.padding(horizontal = 16.dp, vertical = 10.dp), | |
| 94 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 95 | + verticalAlignment = Alignment.CenterVertically, | |
| 96 | + ) { | |
| 97 | + Text(t.name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) | |
| 98 | + Text("${t.count} 次", style = MaterialTheme.typography.labelSmall) | |
| 99 | + Text("›", style = MaterialTheme.typography.titleMedium) | |
| 100 | + } | |
| 101 | + HorizontalDivider() | |
| 102 | + } | |
| 103 | + } | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 111 | +@Composable | |
| 112 | +private fun TopicDetail(vm: PhotologViewModel, topic: TopicRow, onBack: () -> Unit) { | |
| 113 | + val segs by remember(topic.id) { vm.repo.topics.observeSegmentsOf(topic.id) }.collectAsState(initial = emptyList()) | |
| 114 | + Column(Modifier.fillMaxSize()) { | |
| 115 | + TopAppBar( | |
| 116 | + title = { Text(topic.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, | |
| 117 | + navigationIcon = { | |
| 118 | + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } | |
| 119 | + }, | |
| 120 | + ) | |
| 121 | + Text("${kindLabel(topic.kind)} · 在 ${segs.size} 段录音里聊到", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) | |
| 122 | + LazyColumn(Modifier.fillMaxSize()) { | |
| 123 | + items(segs, key = { it.id }) { s -> | |
| 124 | + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | |
| 125 | + Text( | |
| 126 | + (s.personName ?: s.speakerName ?: "—") + " · " + TOPIC_FMT.format(Date(s.startTs)), | |
| 127 | + style = MaterialTheme.typography.labelSmall, | |
| 128 | + ) | |
| 129 | + Text(s.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 3, overflow = TextOverflow.Ellipsis) | |
| 130 | + } | |
| 131 | + HorizontalDivider() | |
| 132 | + } | |
| 133 | + } | |
| 134 | + } | |
| 135 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/TopicExtractor.kt
0 → 100644
| 1 | +package com.lemon.lifelog.crm | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import android.util.Log | |
| 5 | +import com.lemon.lifelog.chat.ChatClient | |
| 6 | +import com.lemon.lifelog.chat.ChatConfig | |
| 7 | +import com.lemon.lifelog.data.Repository | |
| 8 | +import com.lemon.lifelog.data.Topic | |
| 9 | +import com.lemon.lifelog.data.TopicMention | |
| 10 | +import kotlinx.coroutines.Dispatchers | |
| 11 | +import kotlinx.coroutines.withContext | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 端上 CRM #2 话题实体:本地 4B 从录音转写抽 组织/项目/事件 专有名词。 | |
| 15 | + * | |
| 16 | + * 每窗喂 4B 出 `kind|name` 行(kind ∈ org/project/event)→ upsert Topic → | |
| 17 | + * 在该窗各段里**反查子串**命中 → 写 topic_mentions(精确定位到具体段,可点回时间轴)。 | |
| 18 | + * 依赖 llama-swap(8080) 在跑。 | |
| 19 | + */ | |
| 20 | +object TopicExtractor { | |
| 21 | + private const val TAG = "TopicExtractor" | |
| 22 | + private const val RECENT_SEGMENTS = 120 | |
| 23 | + private const val WINDOW = 30 | |
| 24 | + private const val MAX_WINDOWS = 5 | |
| 25 | + private val KINDS = setOf("org", "project", "event") | |
| 26 | + | |
| 27 | + suspend fun scan(ctx: Context, repo: Repository): Int = withContext(Dispatchers.IO) { | |
| 28 | + val rows = repo.segments.timelineList(RECENT_SEGMENTS).filter { !it.text.isNullOrBlank() } | |
| 29 | + if (rows.isEmpty()) return@withContext 0 | |
| 30 | + val windows = rows.chunked(WINDOW).take(MAX_WINDOWS) | |
| 31 | + val target = ChatConfig.targetById(ctx, ChatConfig.ID_4B) | |
| 32 | + var linked = 0 | |
| 33 | + for (w in windows) { | |
| 34 | + val text = w.joinToString("\n") { "- ${it.text}" } | |
| 35 | + val prompt = "下面是录音转写片段。抽出其中提到的 **组织/公司、项目/产品、事件/活动** 的专有名称。" + | |
| 36 | + "每行一条,格式 `类别|名称`,类别只用 org / project / event 之一,名称用原文里的叫法," + | |
| 37 | + "**只基于原文、不编造、不要泛称**。如果没有就只输出:无\n\n$text" | |
| 38 | + val reply = try { | |
| 39 | + ChatClient().stream(target, listOf("user" to prompt)) {} | |
| 40 | + } catch (t: Throwable) { | |
| 41 | + Log.w(TAG, "llm window failed: ${t.message}"); continue | |
| 42 | + } | |
| 43 | + for ((kind, name) in parse(reply)) { | |
| 44 | + val tid = ensureTopic(repo, name, kind) ?: continue | |
| 45 | + val ms = w.filter { it.text?.contains(name) == true } | |
| 46 | + .map { TopicMention(topicId = tid, segmentId = it.id, sourceTs = it.startTs) } | |
| 47 | + if (ms.isNotEmpty()) { repo.topics.insertMentions(ms); linked += ms.size } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + Log.i(TAG, "scan: ${windows.size} windows → $linked topic-mentions") | |
| 51 | + linked | |
| 52 | + } | |
| 53 | + | |
| 54 | + private suspend fun ensureTopic(repo: Repository, name: String, kind: String): Long? { | |
| 55 | + val id = repo.topics.insert(Topic(name = name, kind = kind)) | |
| 56 | + return if (id > 0) id else repo.topics.getId(name, kind) | |
| 57 | + } | |
| 58 | + | |
| 59 | + private fun parse(reply: String): List<Pair<String, String>> = | |
| 60 | + reply.lineSequence() | |
| 61 | + .map { it.trim().trimStart('-', '•', '*', ' ').trim() } | |
| 62 | + .mapNotNull { line -> | |
| 63 | + val parts = line.split("|", "|") | |
| 64 | + if (parts.size < 2) return@mapNotNull null | |
| 65 | + val kind = parts[0].trim().lowercase() | |
| 66 | + val name = parts[1].trim() | |
| 67 | + if (kind !in KINDS) return@mapNotNull null | |
| 68 | + if (name.length !in 2..40 || name == "无") return@mapNotNull null | |
| 69 | + kind to name | |
| 70 | + } | |
| 71 | + .distinct() | |
| 72 | + .take(30) | |
| 73 | + .toList() | |
| 74 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| ... | ... | @@ -25,8 +25,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase |
| 25 | 25 | Mention::class, |
| 26 | 26 | Relation::class, |
| 27 | 27 | Followup::class, |
| 28 | + Topic::class, | |
| 29 | + TopicMention::class, | |
| 28 | 30 | ], |
| 29 | - version = 18, | |
| 31 | + version = 19, | |
| 30 | 32 | exportSchema = false, |
| 31 | 33 | ) |
| 32 | 34 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -43,6 +45,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 43 | 45 | abstract fun mentionDao(): MentionDao |
| 44 | 46 | abstract fun relationDao(): RelationDao |
| 45 | 47 | abstract fun followupDao(): FollowupDao |
| 48 | + abstract fun topicDao(): TopicDao | |
| 46 | 49 | |
| 47 | 50 | companion object { |
| 48 | 51 | @Volatile |
| ... | ... | @@ -198,6 +201,21 @@ abstract class AppDatabase : RoomDatabase() { |
| 198 | 201 | } |
| 199 | 202 | } |
| 200 | 203 | |
| 204 | + /** v0.9 端上 CRM #2:话题实体(组织/项目/事件)+ 提及链接。 */ | |
| 205 | + private val MIGRATION_18_19 = object : Migration(18, 19) { | |
| 206 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 207 | + db.execSQL( | |
| 208 | + "CREATE TABLE IF NOT EXISTS `topics` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `kind` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", | |
| 209 | + ) | |
| 210 | + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name_kind` ON `topics` (`name`, `kind`)") | |
| 211 | + db.execSQL( | |
| 212 | + "CREATE TABLE IF NOT EXISTS `topic_mentions` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `topicId` INTEGER NOT NULL, `segmentId` INTEGER NOT NULL, `sourceTs` INTEGER NOT NULL)", | |
| 213 | + ) | |
| 214 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_topic_mentions_topicId` ON `topic_mentions` (`topicId`)") | |
| 215 | + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_topic_mentions_topicId_segmentId` ON `topic_mentions` (`topicId`, `segmentId`)") | |
| 216 | + } | |
| 217 | + } | |
| 218 | + | |
| 201 | 219 | fun get(ctx: Context): AppDatabase = |
| 202 | 220 | instance ?: synchronized(this) { |
| 203 | 221 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -205,7 +223,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 205 | 223 | AppDatabase::class.java, |
| 206 | 224 | "lifelog.db", |
| 207 | 225 | ) |
| 208 | - .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) | |
| 226 | + .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) | |
| 209 | 227 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 210 | 228 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 211 | 229 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -376,6 +376,59 @@ interface FollowupDao { |
| 376 | 376 | fun observeAll(): Flow<List<FollowupRow>> |
| 377 | 377 | } |
| 378 | 378 | |
| 379 | +/** 话题行(含被提到次数 + 最近时间)。 */ | |
| 380 | +data class TopicRow( | |
| 381 | + val id: Long, | |
| 382 | + val name: String, | |
| 383 | + val kind: String, | |
| 384 | + val count: Int, | |
| 385 | + val lastTs: Long, | |
| 386 | +) | |
| 387 | + | |
| 388 | +@Dao | |
| 389 | +interface TopicDao { | |
| 390 | + @Insert(onConflict = OnConflictStrategy.IGNORE) | |
| 391 | + suspend fun insert(t: Topic): Long | |
| 392 | + | |
| 393 | + @Query("SELECT id FROM topics WHERE name = :name AND kind = :kind") | |
| 394 | + suspend fun getId(name: String, kind: String): Long? | |
| 395 | + | |
| 396 | + @Insert(onConflict = OnConflictStrategy.IGNORE) | |
| 397 | + suspend fun insertMentions(ms: List<TopicMention>) | |
| 398 | + | |
| 399 | + /** 话题列表(按 kind 过滤;count=被提到段数),按热度降序。 */ | |
| 400 | + @Query( | |
| 401 | + """ | |
| 402 | + SELECT t.id AS id, t.name AS name, t.kind AS kind, | |
| 403 | + COUNT(tm.id) AS count, COALESCE(MAX(tm.sourceTs), 0) AS lastTs | |
| 404 | + FROM topics t LEFT JOIN topic_mentions tm ON tm.topicId = t.id | |
| 405 | + GROUP BY t.id | |
| 406 | + HAVING count > 0 | |
| 407 | + ORDER BY count DESC, t.name ASC | |
| 408 | + """, | |
| 409 | + ) | |
| 410 | + fun observeAll(): Flow<List<TopicRow>> | |
| 411 | + | |
| 412 | + /** 某话题被提到的录音段(join 段+转写+说话人)。 */ | |
| 413 | + @Query( | |
| 414 | + """ | |
| 415 | + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | |
| 416 | + s.audioStartMs AS audioStartMs, t.text AS text, s.audioPath AS audioPath, | |
| 417 | + s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName, | |
| 418 | + p.id AS personId, p.name AS personName | |
| 419 | + FROM topic_mentions tm | |
| 420 | + JOIN segments s ON s.id = tm.segmentId | |
| 421 | + LEFT JOIN transcripts t ON t.segmentId = s.id | |
| 422 | + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | |
| 423 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | |
| 424 | + WHERE tm.topicId = :topicId | |
| 425 | + ORDER BY s.startTs DESC | |
| 426 | + LIMIT :limit | |
| 427 | + """, | |
| 428 | + ) | |
| 429 | + fun observeSegmentsOf(topicId: Long, limit: Int = 500): Flow<List<SegmentRow>> | |
| 430 | +} | |
| 431 | + | |
| 379 | 432 | @Dao |
| 380 | 433 | interface RelationDao { |
| 381 | 434 | @Insert(onConflict = OnConflictStrategy.IGNORE) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| ... | ... | @@ -246,3 +246,28 @@ data class Relation( |
| 246 | 246 | val weight: Int, |
| 247 | 247 | val lastTs: Long, |
| 248 | 248 | ) |
| 249 | + | |
| 250 | +/** | |
| 251 | + * 话题实体:本地 LLM 从录音转写里抽出的 组织/项目/事件([kind] = org|project|event)。 | |
| 252 | + * 用一张表 + kind 区分三类(避免三张近乎一样的表)。(name, kind) 唯一。 | |
| 253 | + */ | |
| 254 | +@Entity(tableName = "topics", indices = [Index(value = ["name", "kind"], unique = true)]) | |
| 255 | +data class Topic( | |
| 256 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | |
| 257 | + val name: String, | |
| 258 | + /** "org" | "project" | "event" */ | |
| 259 | + val kind: String, | |
| 260 | + val createdAt: Long = System.currentTimeMillis(), | |
| 261 | +) | |
| 262 | + | |
| 263 | +/** 话题在某段录音里被提到。(topicId, segmentId) 唯一。 */ | |
| 264 | +@Entity( | |
| 265 | + tableName = "topic_mentions", | |
| 266 | + indices = [Index("topicId"), Index(value = ["topicId", "segmentId"], unique = true)], | |
| 267 | +) | |
| 268 | +data class TopicMention( | |
| 269 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | |
| 270 | + val topicId: Long, | |
| 271 | + val segmentId: Long, | |
| 272 | + val sourceTs: Long, | |
| 273 | +) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt