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 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&lt;Person&gt;, 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&lt;Person&gt;, 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
... ... @@ -17,4 +17,5 @@ class Repository(ctx: Context) {
17 17 val mentions = db.mentionDao()
18 18 val relations = db.relationDao()
19 19 val followups = db.followupDao()
  20 + val topics = db.topicDao()
20 21 }
... ...