From 78ae2d69702f712382283e28961ea1e4a9c77fcc Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 10 Jun 2026 11:22:12 +0800 Subject: [PATCH] feat(lifelog/photolog): 档案页 collapsing 滚动 + 简历首 tab + 通讯录一键更新 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 29 ++++++++++++++++++++++++----- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TopicsScreen.kt | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/TopicExtractor.kt | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 22 ++++++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 25 +++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt | 1 + 9 files changed, 543 insertions(+), 151 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TopicsScreen.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/TopicExtractor.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt index c389007..f0dfcb7 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -3,7 +3,9 @@ package com.lemon.lifelog import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.Box @@ -73,7 +75,7 @@ import java.util.Locale private val PROFILE_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable internal fun PersonProfileScreen( vm: PhotologViewModel, @@ -136,96 +138,84 @@ internal fun PersonProfileScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } }, ) - person?.let { p -> - ProfileHero( - p, speakers, - onBind = { bindVoice = true }, - onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, - onEnrich = { vm.enrichImpression(personId) }, - onEditProfile = { editProfile = true }, - onMerge = onRequestMerge, - onDownload = { - Toast.makeText(ctx, "下载中…(读微信本地缓存,首次需授予 root)", Toast.LENGTH_SHORT).show() - vm.downloadWeChatImages(personId) { _, msg -> - Toast.makeText(ctx, msg ?: "完成", Toast.LENGTH_LONG).show() - } - }, - ) - } - OutlinedTextField( - value = query, onValueChange = { query = it }, singleLine = true, - placeholder = { Text("在 TA 的记录里搜索…") }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - ) - ScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { - Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) - Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) - Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) - Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) - Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("被提到 ${mentioned.size}") }) - Tab(selected = tab == 5, onClick = { tab = 5 }, text = { Text("相关 ${related.size}") }) - } - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - when (tab) { - 0 -> if (fPhotos.isEmpty()) EmptyHint(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") - else LazyVerticalGrid( - columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize().padding(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - gridItems(fPhotos, key = { it.id }) { ph -> - AsyncImage( - model = photoModel(ph.source, ph.path), contentDescription = ph.caption, contentScale = ContentScale.Crop, - modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(6.dp)).clickable { onOpenPhoto(ph.id) }, - ) - } + // 整页一个 LazyColumn:hero + 搜索框随滚动一起滑走(当列表项),TabRow 吸顶,再接当前 tab 的内容。 + LazyColumn(modifier = Modifier.fillMaxSize()) { + item(key = "hero") { + person?.let { p -> + ProfileHero( + p, speakers, + onBind = { bindVoice = true }, + onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, + onEnrich = { vm.enrichImpression(personId) }, + onEditProfile = { editProfile = true }, + onMerge = onRequestMerge, + onDownload = { + Toast.makeText(ctx, "下载中…(读微信本地缓存,首次需授予 root)", Toast.LENGTH_SHORT).show() + vm.downloadWeChatImages(personId) { _, msg -> + Toast.makeText(ctx, msg ?: "完成", Toast.LENGTH_LONG).show() + } + }, + ) } - 1 -> if (sid == null) EmptyHint("还没绑定声纹。点上方「绑定」选一个说话人,这里就会列出 TA 说过的录音。") - else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") - else LazyColumn(Modifier.fillMaxSize()) { - items(fAudio, key = { it.id }) { row -> - // 块级管线:turn 是块完整录音里的一段,按 audioStartMs+durationMs 切片才对得上文字。 - AudioRow(row, playing = playingId == row.id) { togglePlay(row) } - } + } + item(key = "search") { + OutlinedTextField( + value = query, onValueChange = { query = it }, singleLine = true, + placeholder = { Text("在 TA 的记录里搜索…") }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + stickyHeader(key = "tabs") { + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("简历") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("照片 ${photos.size}") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("语音 ${audio.size}") }) + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("聊天 ${messages.size}") }) + Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("朋友圈 ${moments.size}") }) + Tab(selected = tab == 5, onClick = { tab = 5 }, text = { Text("被提到 ${mentioned.size}") }) + Tab(selected = tab == 6, onClick = { tab = 6 }, text = { Text("相关 ${related.size}") }) } - 2 -> if (wxid == null) EmptyHint("非微信联系人。") - else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") - else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } - 3 -> if (wxid == null) EmptyHint("非微信联系人。") - else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") - else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo, wxid.orEmpty()) } } - // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 - 4 -> Column(Modifier.fillMaxSize()) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("录音里提到 TA 的段", style = MaterialTheme.typography.labelMedium) - TextButton(onClick = { vm.rescanMentions() }, enabled = !mentionScanning) { - Text(if (mentionScanning) "扫描中…" else "重新扫描") + } + when (tab) { + 0 -> item(key = "resume") { person?.let { p -> ResumeTab(p, onEdit = { editProfile = true }, onEnrich = { vm.enrichImpression(personId) }) } } + 1 -> if (fPhotos.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "还没有关联照片。导入照片后会自动认出 TA。" else "没有匹配的照片。") } + else items(fPhotos.chunked(3), key = { it.first().id }) { rowPhotos -> + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + rowPhotos.forEach { ph -> + AsyncImage( + model = photoModel(ph.source, ph.path), contentDescription = ph.caption, contentScale = ContentScale.Crop, + modifier = Modifier.weight(1f).aspectRatio(1f).clip(RoundedCornerShape(6.dp)).clickable { onOpenPhoto(ph.id) }, + ) } + repeat(3 - rowPhotos.size) { Spacer(Modifier.weight(1f)) } } - if (fMentioned.isEmpty()) { - EmptyHint( - if (q.isBlank()) "还没扫到提及。点「重新扫描」对历史录音反查;新录音会自动入库。\n(需 TA 在「我管理的人物」里,且名字/备注/昵称能在转写文本中被认出)" - else "没有匹配的提及。", - ) - } else { - LazyColumn(Modifier.fillMaxSize()) { - items(fMentioned, key = { it.id }) { row -> - AudioRow(row, playing = playingId == row.id) { togglePlay(row) } + } + 2 -> if (sid == null) item { ProfileEmpty("还没绑定声纹。点上方「🎤 录一段 / 选声纹」绑定,这里就会列出 TA 说过的录音。") } + else if (fAudio.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") } + else items(fAudio, key = { it.id }) { row -> AudioRow(row, playing = playingId == row.id) { togglePlay(row) } } + 3 -> if (wxid == null) item { ProfileEmpty("非微信联系人。") } + else if (fMsgs.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") } + else items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } + 4 -> if (wxid == null) item { ProfileEmpty("非微信联系人。") } + else if (fMoments.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") } + else items(fMoments, key = { it.id }) { mo -> MomentRow(mo, wxid.orEmpty()) } + 5 -> { + item(key = "mhdr") { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, + ) { + Text("录音里提到 TA 的段", style = MaterialTheme.typography.labelMedium) + TextButton(onClick = { vm.rescanMentions() }, enabled = !mentionScanning) { + Text(if (mentionScanning) "扫描中…" else "重新扫描") } } } + if (fMentioned.isEmpty()) item { ProfileEmpty(if (q.isBlank()) "还没扫到提及。点「重新扫描」对历史录音反查;新录音会自动入库。\n(需 TA 在「我管理的人物」里,且名字/备注/昵称能在转写中被认出)" else "没有匹配的提及。") } + else items(fMentioned, key = { it.id }) { row -> AudioRow(row, playing = playingId == row.id) { togglePlay(row) } } } - // 相关:同会话共现(在场/被提到)的人,按共现次数排。 - else -> if (related.isEmpty()) EmptyHint( - "还没有相关的人。共现关系来自录音会话:同一段时间里同时出现/被提到的人会连起来。\n到「被提到」tab 点「重新扫描」可重建。", - ) else LazyColumn(Modifier.fillMaxSize()) { - items(related, key = { it.id }) { rp -> - RelatedRow(rp) { onOpenPerson(rp.id) } - } - } + else -> if (related.isEmpty()) item { ProfileEmpty("还没有相关的人。共现关系来自录音会话:同一段时间里同时出现/被提到的人会连起来。到「被提到」点「重新扫描」可重建。") } + else items(related, key = { it.id }) { rp -> RelatedRow(rp) { onOpenPerson(rp.id) } } } } } @@ -403,31 +393,49 @@ private fun ProfileHero( TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) } } - // ---- 「关于」卡:简历 + AI 印象 ---- + // 「关于/简历」已挪到下方「简历」tab(hero 保持紧凑,给 tab 内容腾出整屏)。 Spacer(Modifier.height(6.dp)) - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text("关于", style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.height(4.dp)) - val resume = p.resume?.takeIf { it.isNotBlank() } - if (resume != null) { - Text(resume, style = MaterialTheme.typography.bodyMedium, maxLines = 6, overflow = TextOverflow.Ellipsis) - } else { - Text("还没有简历。点「✎ 编辑」补充职业 / 公司 / 关系等。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - p.impression?.takeIf { it.isNotBlank() }?.let { - Spacer(Modifier.height(8.dp)) - Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth()) { - Text("🧠 $it", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp)) - } - } + } + } +} + +/** 「简历」tab:真名 + 简历正文 + AI 印象(只读展示;编辑/生成走按钮)。外层 LazyColumn 负责滚动。 */ +@Composable +private fun ResumeTab(p: Person, onEdit: () -> Unit, onEnrich: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + p.realName?.takeIf { it.isNotBlank() }?.let { + Text("真名:$it", style = MaterialTheme.typography.titleSmall) + } + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("简历", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + val resume = p.resume?.takeIf { it.isNotBlank() } + if (resume != null) { + Text(resume, style = MaterialTheme.typography.bodyMedium) + } else { + Text("还没有简历。点「✎ 编辑」补充职业 / 公司 / 关系等。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(6.dp)) + TextButton(onClick = onEdit, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + Text("✎ 编辑真名/简历", style = MaterialTheme.typography.labelMedium) + } + } + } + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("🧠 AI 印象", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + val imp = p.impression?.takeIf { it.isNotBlank() } + Text(imp ?: "还没生成。点下方按钮,本地 4B 看 TA 的朋友圈/聊天画像。", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(6.dp)) + TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + Text(if (imp == null) "🧠 生成 AI 印象" else "🧠 重新生成", style = MaterialTheme.typography.labelMedium) } } - Spacer(Modifier.height(6.dp)) } } } @@ -482,6 +490,14 @@ private fun EmptyHint(text: String) { } } +/** 空状态(用作 LazyColumn item:固定高度居中,避免 fillMaxSize 在 lazy item 里塌成 0)。 */ +@Composable +private fun ProfileEmpty(text: String) { + Box(Modifier.fillMaxWidth().height(260.dp).padding(24.dp), contentAlignment = Alignment.Center) { + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} + @Composable private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, onToggle: () -> Unit) { Row( diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 167547e..54a7edb 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -100,6 +100,7 @@ internal fun ContactsTab(repo: Repository) { var showPhonePicker by remember { mutableStateOf(false) } var showCommunities by remember { mutableStateOf(false) } var showFollowups by remember { mutableStateOf(false) } + var showTopics by remember { mutableStateOf(false) } var mergeForId by remember { mutableStateOf(null) } // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) @@ -122,6 +123,7 @@ internal fun ContactsTab(repo: Repository) { onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, ) showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) + showTopics -> TopicsScreen(vm = vm, onBack = { showTopics = false }) showPicker -> WeChatPickerScreen( vm = vm, onBack = { showPicker = false }, @@ -133,6 +135,7 @@ internal fun ContactsTab(repo: Repository) { onOpenPicker = { showPicker = true }, onOpenCommunities = { showCommunities = true }, onOpenFollowups = { showFollowups = true }, + onOpenTopics = { showTopics = true }, ) } detailPhotoId?.let { id -> @@ -643,7 +646,7 @@ private fun TagFaceDialog( /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit) { +private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit) { var query by remember { mutableStateOf("") } val sections = remember(persons, query) { @@ -662,12 +665,27 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12) } + val ctx = LocalContext.current + val updating by vm.updating.collectAsState() Column(modifier = Modifier.fillMaxSize()) { - OutlinedTextField( - value = query, onValueChange = { query = it }, - placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true, + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - ) + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = query, onValueChange = { query = it }, + placeholder = { Text("搜索我的人物(共 ${persons.size})") }, singleLine = true, + modifier = Modifier.weight(1f), + ) + // 🔄 一键更新所有有微信的管理人物:头像+朋友圈 + 关系/待办/提及 + 相册识别(不碰录音)。 + Button( + enabled = !updating, + onClick = { + android.widget.Toast.makeText(ctx, "更新中…(逐个读微信本地缓存,人多会慢)", android.widget.Toast.LENGTH_SHORT).show() + vm.updateAllManaged { msg -> android.widget.Toast.makeText(ctx, msg, android.widget.Toast.LENGTH_LONG).show() } + }, + ) { Text(if (updating) "更新中" else "🔄 更新") } + } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -675,6 +693,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L Button(onClick = onOpenPicker) { Text("📇 通讯录") } OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } + OutlinedButton(onClick = onOpenTopics) { Text("🏷 话题") } } HorizontalDivider() if (persons.isEmpty()) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 85e3434..1114699 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -186,6 +186,18 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) } + /** 话题实体:本地 4B 从最近录音抽 组织/项目/事件(需 llama-swap 在跑)。 */ + private val _topicScanning = MutableStateFlow(false) + val topicScanning: StateFlow = _topicScanning + fun scanTopics() { + if (_topicScanning.value) return + _topicScanning.value = true + viewModelScope.launch { + runCatching { com.lemon.lifelog.crm.TopicExtractor.scan(getApplication(), repo) } + _topicScanning.value = false + } + } + fun clearFace(faceId: Long) { viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } } @@ -466,52 +478,91 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { * 回调 (头像张数, 人读消息)。 */ fun downloadWeChatImages(personId: Long, onResult: (Int, String?) -> Unit) { - val app = getApplication() viewModelScope.launch { - val (added, msg) = withContext(Dispatchers.IO) { - val p = repo.persons.get(personId) ?: return@withContext 0 to "人物不存在" - val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } - ?: return@withContext 0 to "不是微信联系人,无法下载" - val photosDir = File(app.filesDir, "photos").apply { mkdirs() } + val (avatarN, momentN, msg) = withContext(Dispatchers.IO) { downloadWeChatCore(personId) } + onResult(avatarN, msg) + } + } - // 清掉旧版误倒进相册的朋友圈图(path 形如 photos/moment__*.jpg)+ 其归属脸。 - runCatching { - for (id in repo.photos.idsByPathLike("%/moment_${wxid}_%")) { - repo.faces.deleteByPhoto(id); repo.photos.delete(id) - } - } + /** 单人微信下载核心(**IO 线程调**):头像→照片(归属脸) + 朋友圈按条存。返回 (头像数, 朋友圈条数, 人读消息)。 */ + private suspend fun downloadWeChatCore(personId: Long): Triple { + val app = getApplication() + val p = repo.persons.get(personId) ?: return Triple(0, 0, "人物不存在") + val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } + ?: return Triple(0, 0, "不是微信联系人") + val photosDir = File(app.filesDir, "photos").apply { mkdirs() } + // 清掉旧版误倒进相册的朋友圈图(path 形如 photos/moment__*.jpg)+ 其归属脸。 + runCatching { + for (id in repo.photos.idsByPathLike("%/moment_${wxid}_%")) { + repo.faces.deleteByPhoto(id); repo.photos.delete(id) + } + } + // 1) 头像 → 照片(归属脸)。 + var avatarN = 0 + val avatar = File(app.filesDir, "wx_enroll/avatars/$wxid.jpg") + if (avatar.exists()) { + val dst = File(photosDir, "wxavatar_$wxid.jpg") + if (repo.photos.getByPath(dst.absolutePath) == null) { + runCatching { avatar.copyTo(dst, overwrite = true); importOwnedPhoto(dst, personId, p.createdAt); avatarN++ } + } + } + // 2) 朋友圈:图按条存到 moments//,文字进 moments 表(不进照片相册)。 + val momentsDir = File(app.filesDir, "moments/$wxid").apply { deleteRecursively(); mkdirs() } + val r = WeChatSnsImporter.importMomentsFor(app, wxid, momentsDir) + var momentN = 0 + var imgN = 0 + if (r.moments.isNotEmpty()) { + repo.moments.clearUser(wxid) + repo.moments.insertAll(r.moments.map { Moment(userName = wxid, text = it.text, createTime = it.createTimeMs) }) + momentN = r.moments.size + imgN = r.moments.sumOf { it.images.size } + } + if (avatarN > 0 || momentN > 0) { + repo.persons.setManaged(personId, true) + repo.persons.touch(personId, System.currentTimeMillis()) + if (avatarN > 0) PhotoScheduler.runNow(app) + } + val ok = avatarN > 0 || momentN > 0 + return Triple(avatarN, momentN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容")) + } - // 1) 头像 → 照片(归属脸)。 - var avatarN = 0 - val avatar = File(app.filesDir, "wx_enroll/avatars/$wxid.jpg") - if (avatar.exists()) { - val dst = File(photosDir, "wxavatar_$wxid.jpg") - if (repo.photos.getByPath(dst.absolutePath) == null) { - runCatching { avatar.copyTo(dst, overwrite = true); importOwnedPhoto(dst, personId, p.createdAt); avatarN++ } + /** + * 通讯录「🔄 更新」:批量更新**所有有微信的管理人物**。 + * - 微信:逐个 downloadWeChatCore(头像+朋友圈图+文字); + * - 派生分析(全局一次):提及反查(MentionScanner) + 人↔人共现(RelationBuilder) + 待办(FollowupExtractor); + * - 图片识别:催一遍全相册扫描 + VL。 + * 不碰录音(24/7 后台自动转写/认人)。聊天暂不本地更新(EnMicroMsg 加密,仍靠 Mac enroll)。 + */ + private val _updating = MutableStateFlow(false) + val updating: StateFlow = _updating + fun updateAllManaged(onDone: (String) -> Unit) { + if (_updating.value) return + _updating.value = true + val app = getApplication() + viewModelScope.launch { + val summary = withContext(Dispatchers.IO) { + val targets = repo.persons.all().filter { it.managed && it.contactRef?.startsWith("wechat:") == true } + var people = 0 + var moments = 0 + for (p in targets) { + runCatching { + val (a, m, _) = downloadWeChatCore(p.id) + if (a > 0 || m > 0) people++ + moments += m } } - - // 2) 朋友圈:图按条存到 moments//,文字进 moments 表(不进照片相册)。 - val momentsDir = File(app.filesDir, "moments/$wxid").apply { deleteRecursively(); mkdirs() } - val r = WeChatSnsImporter.importMomentsFor(app, wxid, momentsDir) - var momentN = 0 - var imgN = 0 - if (r.moments.isNotEmpty()) { - repo.moments.clearUser(wxid) - repo.moments.insertAll(r.moments.map { Moment(userName = wxid, text = it.text, createTime = it.createTimeMs) }) - momentN = r.moments.size - imgN = r.moments.sumOf { it.images.size } - } - - if (avatarN > 0 || momentN > 0) { - repo.persons.setManaged(personId, true) - repo.persons.touch(personId, System.currentTimeMillis()) - if (avatarN > 0) PhotoScheduler.runNow(app) // 让后台对头像跑人脸 + // 派生分析(全局各一次) + runCatching { + com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) + com.lemon.lifelog.crm.RelationBuilder.rebuild(repo) } - val ok = avatarN > 0 || momentN > 0 - Pair(avatarN, if (ok) "已下载头像 + $momentN 条朋友圈($imgN 张图)" else (r.error ?: "没有可下载的内容")) + runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(app, repo) } + "已更新 $people 人 · $moments 条朋友圈;关系/待办/提及已重建" } - onResult(added, msg) + // 图片识别:催全相册扫描 + VL(enqueue,不阻塞)。 + scanGallery(); runVlNow() + _updating.value = false + onDone("$summary;相册识别已催") } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TopicsScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TopicsScreen.kt new file mode 100644 index 0000000..118724a --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/TopicsScreen.kt @@ -0,0 +1,135 @@ +package com.lemon.lifelog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lemon.lifelog.data.TopicRow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val TOPIC_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) +private fun kindLabel(k: String) = when (k) { "org" -> "公司 / 组织"; "project" -> "项目 / 产品"; "event" -> "事件 / 活动"; else -> k } + +/** + * 话题(组织/项目/事件):本地 4B 从录音抽出,按类别分组。点话题看在哪些录音里聊到。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TopicsScreen(vm: PhotologViewModel, onBack: () -> Unit) { + val topics by vm.repo.topics.observeAll().collectAsState(initial = emptyList()) + val scanning by vm.topicScanning.collectAsState() + var selected by remember { mutableStateOf(null) } + + val sel = selected + if (sel != null) { + TopicDetail(vm, sel, onBack = { selected = null }) + return + } + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("话题") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } + }, + actions = { + TextButton(onClick = { vm.scanTopics() }, enabled = !scanning) { + Text(if (scanning) "扫描中…" else "扫描") + } + }, + ) + if (topics.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + if (scanning) "本地 4B 正在从最近录音里抽 组织/项目/事件…" + else "还没有话题。点右上「扫描」让本地 4B 从最近录音里抽 组织/项目/事件。\n(需 llama-swap 在跑)", + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + // 按 kind 分组:org / project / event + val grouped = topics.groupBy { it.kind } + LazyColumn(Modifier.fillMaxSize()) { + listOf("org", "project", "event").forEach { kind -> + val list = grouped[kind].orEmpty() + if (list.isNotEmpty()) { + item(key = "h_$kind") { + Text( + "${kindLabel(kind)}(${list.size})", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + items(list, key = { it.id }) { t -> + Row( + Modifier.fillMaxWidth().clickable { selected = t }.padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(t.name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + Text("${t.count} 次", style = MaterialTheme.typography.labelSmall) + Text("›", style = MaterialTheme.typography.titleMedium) + } + HorizontalDivider() + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopicDetail(vm: PhotologViewModel, topic: TopicRow, onBack: () -> Unit) { + val segs by remember(topic.id) { vm.repo.topics.observeSegmentsOf(topic.id) }.collectAsState(initial = emptyList()) + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(topic.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } + }, + ) + Text("${kindLabel(topic.kind)} · 在 ${segs.size} 段录音里聊到", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) + LazyColumn(Modifier.fillMaxSize()) { + items(segs, key = { it.id }) { s -> + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + Text( + (s.personName ?: s.speakerName ?: "—") + " · " + TOPIC_FMT.format(Date(s.startTs)), + style = MaterialTheme.typography.labelSmall, + ) + Text(s.text ?: "(无转写)", style = MaterialTheme.typography.bodyMedium, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + HorizontalDivider() + } + } + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/TopicExtractor.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/TopicExtractor.kt new file mode 100644 index 0000000..4b7b186 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/TopicExtractor.kt @@ -0,0 +1,74 @@ +package com.lemon.lifelog.crm + +import android.content.Context +import android.util.Log +import com.lemon.lifelog.chat.ChatClient +import com.lemon.lifelog.chat.ChatConfig +import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.data.Topic +import com.lemon.lifelog.data.TopicMention +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 端上 CRM #2 话题实体:本地 4B 从录音转写抽 组织/项目/事件 专有名词。 + * + * 每窗喂 4B 出 `kind|name` 行(kind ∈ org/project/event)→ upsert Topic → + * 在该窗各段里**反查子串**命中 → 写 topic_mentions(精确定位到具体段,可点回时间轴)。 + * 依赖 llama-swap(8080) 在跑。 + */ +object TopicExtractor { + private const val TAG = "TopicExtractor" + private const val RECENT_SEGMENTS = 120 + private const val WINDOW = 30 + private const val MAX_WINDOWS = 5 + private val KINDS = setOf("org", "project", "event") + + suspend fun scan(ctx: Context, repo: Repository): Int = withContext(Dispatchers.IO) { + val rows = repo.segments.timelineList(RECENT_SEGMENTS).filter { !it.text.isNullOrBlank() } + if (rows.isEmpty()) return@withContext 0 + val windows = rows.chunked(WINDOW).take(MAX_WINDOWS) + val target = ChatConfig.targetById(ctx, ChatConfig.ID_4B) + var linked = 0 + for (w in windows) { + val text = w.joinToString("\n") { "- ${it.text}" } + val prompt = "下面是录音转写片段。抽出其中提到的 **组织/公司、项目/产品、事件/活动** 的专有名称。" + + "每行一条,格式 `类别|名称`,类别只用 org / project / event 之一,名称用原文里的叫法," + + "**只基于原文、不编造、不要泛称**。如果没有就只输出:无\n\n$text" + val reply = try { + ChatClient().stream(target, listOf("user" to prompt)) {} + } catch (t: Throwable) { + Log.w(TAG, "llm window failed: ${t.message}"); continue + } + for ((kind, name) in parse(reply)) { + val tid = ensureTopic(repo, name, kind) ?: continue + val ms = w.filter { it.text?.contains(name) == true } + .map { TopicMention(topicId = tid, segmentId = it.id, sourceTs = it.startTs) } + if (ms.isNotEmpty()) { repo.topics.insertMentions(ms); linked += ms.size } + } + } + Log.i(TAG, "scan: ${windows.size} windows → $linked topic-mentions") + linked + } + + private suspend fun ensureTopic(repo: Repository, name: String, kind: String): Long? { + val id = repo.topics.insert(Topic(name = name, kind = kind)) + return if (id > 0) id else repo.topics.getId(name, kind) + } + + private fun parse(reply: String): List> = + reply.lineSequence() + .map { it.trim().trimStart('-', '•', '*', ' ').trim() } + .mapNotNull { line -> + val parts = line.split("|", "|") + if (parts.size < 2) return@mapNotNull null + val kind = parts[0].trim().lowercase() + val name = parts[1].trim() + if (kind !in KINDS) return@mapNotNull null + if (name.length !in 2..40 || name == "无") return@mapNotNull null + kind to name + } + .distinct() + .take(30) + .toList() +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index e56e66e..5f2c834 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -25,8 +25,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase Mention::class, Relation::class, Followup::class, + Topic::class, + TopicMention::class, ], - version = 18, + version = 19, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -43,6 +45,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun mentionDao(): MentionDao abstract fun relationDao(): RelationDao abstract fun followupDao(): FollowupDao + abstract fun topicDao(): TopicDao companion object { @Volatile @@ -198,6 +201,21 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.9 端上 CRM #2:话题实体(组织/项目/事件)+ 提及链接。 */ + private val MIGRATION_18_19 = object : Migration(18, 19) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "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)", + ) + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name_kind` ON `topics` (`name`, `kind`)") + db.execSQL( + "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)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_topic_mentions_topicId` ON `topic_mentions` (`topicId`)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_topic_mentions_topicId_segmentId` ON `topic_mentions` (`topicId`, `segmentId`)") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -205,7 +223,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18) + .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) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 1ec797f..2de5fd7 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -376,6 +376,59 @@ interface FollowupDao { fun observeAll(): Flow> } +/** 话题行(含被提到次数 + 最近时间)。 */ +data class TopicRow( + val id: Long, + val name: String, + val kind: String, + val count: Int, + val lastTs: Long, +) + +@Dao +interface TopicDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(t: Topic): Long + + @Query("SELECT id FROM topics WHERE name = :name AND kind = :kind") + suspend fun getId(name: String, kind: String): Long? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertMentions(ms: List) + + /** 话题列表(按 kind 过滤;count=被提到段数),按热度降序。 */ + @Query( + """ + SELECT t.id AS id, t.name AS name, t.kind AS kind, + COUNT(tm.id) AS count, COALESCE(MAX(tm.sourceTs), 0) AS lastTs + FROM topics t LEFT JOIN topic_mentions tm ON tm.topicId = t.id + GROUP BY t.id + HAVING count > 0 + ORDER BY count DESC, t.name ASC + """, + ) + fun observeAll(): Flow> + + /** 某话题被提到的录音段(join 段+转写+说话人)。 */ + @Query( + """ + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, t.text AS text, s.audioPath AS audioPath, + s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName, + p.id AS personId, p.name AS personName + FROM topic_mentions tm + JOIN segments s ON s.id = tm.segmentId + LEFT JOIN transcripts t ON t.segmentId = s.id + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId + WHERE tm.topicId = :topicId + ORDER BY s.startTs DESC + LIMIT :limit + """, + ) + fun observeSegmentsOf(topicId: Long, limit: Int = 500): Flow> +} + @Dao interface RelationDao { @Insert(onConflict = OnConflictStrategy.IGNORE) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index b801e0d..1602ec4 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -246,3 +246,28 @@ data class Relation( val weight: Int, val lastTs: Long, ) + +/** + * 话题实体:本地 LLM 从录音转写里抽出的 组织/项目/事件([kind] = org|project|event)。 + * 用一张表 + kind 区分三类(避免三张近乎一样的表)。(name, kind) 唯一。 + */ +@Entity(tableName = "topics", indices = [Index(value = ["name", "kind"], unique = true)]) +data class Topic( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + /** "org" | "project" | "event" */ + val kind: String, + val createdAt: Long = System.currentTimeMillis(), +) + +/** 话题在某段录音里被提到。(topicId, segmentId) 唯一。 */ +@Entity( + tableName = "topic_mentions", + indices = [Index("topicId"), Index(value = ["topicId", "segmentId"], unique = true)], +) +data class TopicMention( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val topicId: Long, + val segmentId: Long, + val sourceTs: Long, +) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt index 5397e39..c710795 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt @@ -17,4 +17,5 @@ class Repository(ctx: Context) { val mentions = db.mentionDao() val relations = db.relationDao() val followups = db.followupDao() + val topics = db.topicDao() } -- libgit2 0.21.4