Commit 78ae2d69702f712382283e28961ea1e4a9c77fcc

Authored by yibo
1 parent aef8755d

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>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
@@ -3,7 +3,9 @@ package com.lemon.lifelog @@ -3,7 +3,9 @@ package com.lemon.lifelog
3 import androidx.compose.foundation.background 3 import androidx.compose.foundation.background
4 import androidx.compose.foundation.clickable 4 import androidx.compose.foundation.clickable
5 import androidx.compose.foundation.horizontalScroll 5 import androidx.compose.foundation.horizontalScroll
  6 +import androidx.compose.foundation.ExperimentalFoundationApi
6 import androidx.compose.foundation.rememberScrollState 7 import androidx.compose.foundation.rememberScrollState
  8 +import androidx.compose.foundation.verticalScroll
7 import androidx.compose.foundation.layout.Arrangement 9 import androidx.compose.foundation.layout.Arrangement
8 import androidx.compose.foundation.layout.offset 10 import androidx.compose.foundation.layout.offset
9 import androidx.compose.foundation.layout.Box 11 import androidx.compose.foundation.layout.Box
@@ -73,7 +75,7 @@ import java.util.Locale @@ -73,7 +75,7 @@ import java.util.Locale
73 75
74 private val PROFILE_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) 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 @Composable 79 @Composable
78 internal fun PersonProfileScreen( 80 internal fun PersonProfileScreen(
79 vm: PhotologViewModel, 81 vm: PhotologViewModel,
@@ -136,96 +138,84 @@ internal fun PersonProfileScreen( @@ -136,96 +138,84 @@ internal fun PersonProfileScreen(
136 IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } 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,31 +393,49 @@ private fun ProfileHero(
403 TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) } 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 Spacer(Modifier.height(6.dp)) 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,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 @Composable 501 @Composable
486 private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, onToggle: () -> Unit) { 502 private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, onToggle: () -> Unit) {
487 Row( 503 Row(
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
@@ -100,6 +100,7 @@ internal fun ContactsTab(repo: Repository) { @@ -100,6 +100,7 @@ internal fun ContactsTab(repo: Repository) {
100 var showPhonePicker by remember { mutableStateOf(false) } 100 var showPhonePicker by remember { mutableStateOf(false) }
101 var showCommunities by remember { mutableStateOf(false) } 101 var showCommunities by remember { mutableStateOf(false) }
102 var showFollowups by remember { mutableStateOf(false) } 102 var showFollowups by remember { mutableStateOf(false) }
  103 + var showTopics by remember { mutableStateOf(false) }
103 var mergeForId by remember { mutableStateOf<Long?>(null) } 104 var mergeForId by remember { mutableStateOf<Long?>(null) }
104 // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 105 // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。
105 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) 106 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList())
@@ -122,6 +123,7 @@ internal fun ContactsTab(repo: Repository) { @@ -122,6 +123,7 @@ internal fun ContactsTab(repo: Repository) {
122 onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, 123 onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it },
123 ) 124 )
124 showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) 125 showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false })
  126 + showTopics -> TopicsScreen(vm = vm, onBack = { showTopics = false })
125 showPicker -> WeChatPickerScreen( 127 showPicker -> WeChatPickerScreen(
126 vm = vm, 128 vm = vm,
127 onBack = { showPicker = false }, 129 onBack = { showPicker = false },
@@ -133,6 +135,7 @@ internal fun ContactsTab(repo: Repository) { @@ -133,6 +135,7 @@ internal fun ContactsTab(repo: Repository) {
133 onOpenPicker = { showPicker = true }, 135 onOpenPicker = { showPicker = true },
134 onOpenCommunities = { showCommunities = true }, 136 onOpenCommunities = { showCommunities = true },
135 onOpenFollowups = { showFollowups = true }, 137 onOpenFollowups = { showFollowups = true },
  138 + onOpenTopics = { showTopics = true },
136 ) 139 )
137 } 140 }
138 detailPhotoId?.let { id -> 141 detailPhotoId?.let { id ->
@@ -643,7 +646,7 @@ private fun TagFaceDialog( @@ -643,7 +646,7 @@ private fun TagFaceDialog(
643 /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ 646 /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */
644 @OptIn(ExperimentalFoundationApi::class) 647 @OptIn(ExperimentalFoundationApi::class)
645 @Composable 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 var query by remember { mutableStateOf("") } 650 var query by remember { mutableStateOf("") }
648 651
649 val sections = remember(persons, query) { 652 val sections = remember(persons, query) {
@@ -662,12 +665,27 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -662,12 +665,27 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
662 else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12) 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 Column(modifier = Modifier.fillMaxSize()) { 670 Column(modifier = Modifier.fillMaxSize()) {
666 - OutlinedTextField(  
667 - value = query, onValueChange = { query = it },  
668 - placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true, 671 + Row(
669 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), 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 Row( 689 Row(
672 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), 690 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp),
673 verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), 691 verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -675,6 +693,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -675,6 +693,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
675 Button(onClick = onOpenPicker) { Text("📇 通讯录") } 693 Button(onClick = onOpenPicker) { Text("📇 通讯录") }
676 OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } 694 OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") }
677 OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } 695 OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") }
  696 + OutlinedButton(onClick = onOpenTopics) { Text("🏷 话题") }
678 } 697 }
679 HorizontalDivider() 698 HorizontalDivider()
680 if (persons.isEmpty()) { 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,6 +186,18 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
186 fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } 186 fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) }
187 fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) } 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 fun clearFace(faceId: Long) { 201 fun clearFace(faceId: Long) {
190 viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } 202 viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) }
191 } 203 }
@@ -466,52 +478,91 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -466,52 +478,91 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
466 * 回调 (头像张数, 人读消息)。 478 * 回调 (头像张数, 人读消息)。
467 */ 479 */
468 fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { 480 fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) {
469 - val app = getApplication<Application>()  
470 viewModelScope.launch { 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,8 +25,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase
25 Mention::class, 25 Mention::class,
26 Relation::class, 26 Relation::class,
27 Followup::class, 27 Followup::class,
  28 + Topic::class,
  29 + TopicMention::class,
28 ], 30 ],
29 - version = 18, 31 + version = 19,
30 exportSchema = false, 32 exportSchema = false,
31 ) 33 )
32 abstract class AppDatabase : RoomDatabase() { 34 abstract class AppDatabase : RoomDatabase() {
@@ -43,6 +45,7 @@ abstract class AppDatabase : RoomDatabase() { @@ -43,6 +45,7 @@ abstract class AppDatabase : RoomDatabase() {
43 abstract fun mentionDao(): MentionDao 45 abstract fun mentionDao(): MentionDao
44 abstract fun relationDao(): RelationDao 46 abstract fun relationDao(): RelationDao
45 abstract fun followupDao(): FollowupDao 47 abstract fun followupDao(): FollowupDao
  48 + abstract fun topicDao(): TopicDao
46 49
47 companion object { 50 companion object {
48 @Volatile 51 @Volatile
@@ -198,6 +201,21 @@ abstract class AppDatabase : RoomDatabase() { @@ -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 fun get(ctx: Context): AppDatabase = 219 fun get(ctx: Context): AppDatabase =
202 instance ?: synchronized(this) { 220 instance ?: synchronized(this) {
203 instance ?: Room.databaseBuilder( 221 instance ?: Room.databaseBuilder(
@@ -205,7 +223,7 @@ abstract class AppDatabase : RoomDatabase() { @@ -205,7 +223,7 @@ abstract class AppDatabase : RoomDatabase() {
205 AppDatabase::class.java, 223 AppDatabase::class.java,
206 "lifelog.db", 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 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, 227 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
210 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 228 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
211 .build() 229 .build()
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
@@ -376,6 +376,59 @@ interface FollowupDao { @@ -376,6 +376,59 @@ interface FollowupDao {
376 fun observeAll(): Flow<List<FollowupRow>> 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 @Dao 432 @Dao
380 interface RelationDao { 433 interface RelationDao {
381 @Insert(onConflict = OnConflictStrategy.IGNORE) 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,3 +246,28 @@ data class Relation(
246 val weight: Int, 246 val weight: Int,
247 val lastTs: Long, 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
@@ -17,4 +17,5 @@ class Repository(ctx: Context) { @@ -17,4 +17,5 @@ class Repository(ctx: Context) {
17 val mentions = db.mentionDao() 17 val mentions = db.mentionDao()
18 val relations = db.relationDao() 18 val relations = db.relationDao()
19 val followups = db.followupDao() 19 val followups = db.followupDao()
  20 + val topics = db.topicDao()
20 } 21 }