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 39d994e..9e7abf6 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -47,8 +48,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.lemon.lifelog.audio.SegmentPlayer +import com.lemon.lifelog.data.Moment import com.lemon.lifelog.data.Person import com.lemon.lifelog.data.SpeakerProfile +import com.lemon.lifelog.data.WechatMessage import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -72,6 +75,15 @@ internal fun PersonProfileScreen( if (sid == null) kotlinx.coroutines.flow.flowOf(emptyList()) else vm.repo.segments.observeBySpeaker(sid) }.collectAsState(initial = emptyList()) + val wxid = person?.contactRef?.removePrefix("wechat:")?.takeIf { person?.contactRef?.startsWith("wechat:") == true } + val messages by remember(wxid) { + if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) + else vm.repo.wechatMessages.observeByTalker(wxid) + }.collectAsState(initial = emptyList()) + val moments by remember(wxid) { + if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) + else vm.repo.moments.observeByUser(wxid) + }.collectAsState(initial = emptyList()) var bindVoice by remember { mutableStateOf(false) } val player = remember { SegmentPlayer() } @@ -116,6 +128,17 @@ internal fun PersonProfileScreen( Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } } + // AI 印象 + Spacer(Modifier.height(6.dp)) + p.impression?.let { + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { + Text("🧠 $it", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp)) + } + Spacer(Modifier.height(6.dp)) + } + OutlinedButton(onClick = { vm.enrichImpression(personId) }) { + Text(if (p.impression == null) "生成 AI 印象(本地 4B,看朋友圈)" else "重新生成印象") + } } HorizontalDivider() } @@ -169,6 +192,24 @@ internal fun PersonProfileScreen( } } } + // ----- 微信聊天 ----- + item { HorizontalDivider(); SectionHeader("微信聊天", messages.size) } + if (wxid == null) { + item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } + } else if (messages.isEmpty()) { + item { Text("这台设备上没有跟 TA 的聊天记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } + } else { + items(messages, key = { it.id }) { m -> ChatBubble(m) } + } + // ----- 朋友圈 ----- + item { HorizontalDivider(); SectionHeader("朋友圈", moments.size) } + if (wxid == null) { + item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } + } else if (moments.isEmpty()) { + item { Text("没有 TA 的朋友圈记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } + } else { + items(moments, key = { it.id }) { mo -> MomentRow(mo) } + } item { Spacer(Modifier.height(24.dp)) } } } @@ -185,6 +226,30 @@ internal fun PersonProfileScreen( } @Composable +private fun MomentRow(m: Moment) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) + Text(m.text, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun ChatBubble(m: WechatMessage) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 3.dp), + horizontalArrangement = if (m.isSend) Arrangement.End else Arrangement.Start, + ) { + Surface( + color = if (m.isSend) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.widthIn(max = 280.dp), + ) { + Text(m.content, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable private fun InfoRow(label: String, value: String?) { if (value.isNullOrBlank()) return Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { 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 f7c08a4..4204005 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 @@ -11,6 +11,8 @@ import com.lemon.lifelog.data.Repository import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager +import com.lemon.lifelog.chat.ChatClient +import com.lemon.lifelog.chat.ChatConfig import com.lemon.lifelog.vision.FaceMatcher import com.lemon.lifelog.vision.ImageUtil import com.lemon.lifelog.vision.PhotoScheduler @@ -79,6 +81,30 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } } + /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */ + fun enrichImpression(personId: Long) { + viewModelScope.launch { + val p = withContext(Dispatchers.IO) { repo.persons.get(personId) } ?: return@launch + val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } + val moments = if (wxid != null) withContext(Dispatchers.IO) { repo.moments.byUser(wxid, 40).map { it.text } } else emptyList() + if (moments.isEmpty()) { + repo.persons.updateImpression(personId, "(朋友圈/聊天资料不足,无法生成画像)") + return@launch + } + repo.persons.updateImpression(personId, "生成中…") + try { + val prompt = "下面是「${p.name}」的微信朋友圈片段。请用中文输出对 TA 的**一句话画像** + " + + "**3–6 个标签**(爱好/性格/职业方向/星座倾向等,只基于片段、不编造)。格式:先一句话,再「标签:A、B、C」。\n\n" + + moments.joinToString("\n") { "- ${it.take(80)}" } + val target = ChatConfig.targetById(getApplication(), ChatConfig.ID_4B) + val full = ChatClient().stream(target, listOf("user" to prompt)) {} + repo.persons.updateImpression(personId, full.trim().ifBlank { "(生成为空)" }) + } catch (t: Throwable) { + repo.persons.updateImpression(personId, "⚠ 生成失败:${t.message}") + } + } + } + /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */ fun wechatEnrollReady(): Boolean = File(getApplication().filesDir, "wx_enroll/enroll.json").exists() 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 707b215..7502c7f 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 @@ -19,8 +19,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase Person::class, Photo::class, Face::class, + WechatMessage::class, + Moment::class, ], - version = 6, + version = 9, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -32,6 +34,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun personDao(): PersonDao abstract fun photoDao(): PhotoDao abstract fun faceDao(): FaceDao + abstract fun wechatMessageDao(): WechatMessageDao + abstract fun momentDao(): MomentDao companion object { @Volatile @@ -80,6 +84,25 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.7 人脉里程碑2:微信聊天消息表。 */ + private val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `wechat_messages` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `talker` TEXT NOT NULL, `content` TEXT NOT NULL, `createTime` INTEGER NOT NULL, `isSend` INTEGER NOT NULL)", + ) + } + } + + /** v0.7 人脉里程碑4/5:朋友圈表 + persons.impression(AI 印象)。 */ + private val MIGRATION_7_9 = object : Migration(7, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `moments` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userName` TEXT NOT NULL, `text` TEXT NOT NULL, `createTime` INTEGER NOT NULL)", + ) + db.execSQL("ALTER TABLE `persons` ADD COLUMN `impression` TEXT") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -87,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9) // 不再 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 e621296..026cec5 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 @@ -266,6 +266,9 @@ interface PersonDao { @Query("UPDATE persons SET faceEmbedding = :emb WHERE id = :id") suspend fun updateFaceEmbedding(id: Long, emb: ByteArray) + @Query("UPDATE persons SET impression = :text WHERE id = :id") + suspend fun updateImpression(id: Long, text: String) + @Query("UPDATE persons SET name = :name WHERE id = :id") suspend fun updateName(id: Long, name: String) @@ -358,6 +361,40 @@ interface FaceDao { } @Dao +interface MomentDao { + @Insert + suspend fun insertAll(m: List) + + @Query("DELETE FROM moments") + suspend fun clear() + + @Query("SELECT COUNT(*) FROM moments") + suspend fun count(): Int + + @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC") + fun observeByUser(u: String): Flow> + + @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC LIMIT :n") + suspend fun byUser(u: String, n: Int): List +} + +@Dao +interface WechatMessageDao { + @Insert + suspend fun insertAll(msgs: List) + + @Query("DELETE FROM wechat_messages") + suspend fun clear() + + @Query("SELECT COUNT(*) FROM wechat_messages") + suspend fun count(): Int + + /** 某联系人(wxid)的聊天,时间正序。 */ + @Query("SELECT * FROM wechat_messages WHERE talker = :talker ORDER BY createTime ASC") + fun observeByTalker(talker: String): Flow> +} + +@Dao interface ChatDao { @Insert suspend fun insertConversation(c: ChatConversation): Long 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 0b10422..e4bdad9 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 @@ -103,7 +103,9 @@ data class Person( val alias: String? = null, // 微信号 val pinyin: String? = null, // 全拼(排序) val pyInitial: String? = null, // 首字母(A–Z 索引) - val phone: String? = null, // 手机号(未来短信关联,先占位) + val phone: String? = null, // 手机号(短信关联) + /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ + val impression: String? = null, val createdAt: Long = System.currentTimeMillis(), ) @@ -124,6 +126,25 @@ data class Photo( val processedAt: Long? = null, ) +/** 朋友圈(从 SnsMicroMsg SnsInfo 表导入,userName=发布者 wxid,text=正文)。 */ +@Entity(tableName = "moments") +data class Moment( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val userName: String, + val text: String, + val createTime: Long, +) + +/** 微信聊天消息(从 EnMicroMsg message 表导入,talker=对方 wxid,关联 Person.contactRef)。 */ +@Entity(tableName = "wechat_messages") +data class WechatMessage( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val talker: String, // 对方 wxid + val content: String, + val createTime: Long, // 毫秒 + val isSend: Boolean, // true = 我发的 +) + /** 照片里检测到的一张脸:bbox + ArcFace embedding + 归属 Person。结构同 SpeakerProfile 的脸版。 */ @Entity(tableName = "faces") data class Face( 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 a4fdc87..9c8abfa 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 @@ -12,4 +12,6 @@ class Repository(ctx: Context) { val persons = db.personDao() val photos = db.photoDao() val faces = db.faceDao() + val wechatMessages = db.wechatMessageDao() + val moments = db.momentDao() } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt index 3638e51..1b13f42 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt @@ -4,8 +4,10 @@ import android.content.Context import android.util.Log import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.lemon.lifelog.data.Moment import com.lemon.lifelog.data.Person import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.data.WechatMessage import org.json.JSONArray import java.io.File import java.util.concurrent.atomic.AtomicBoolean @@ -62,6 +64,49 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork } Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})") + // ---- Pass 1.5: 微信聊天导入(clear + 重灌,幂等)---- + val msgFile = File(dir, "messages.json") + if (msgFile.exists()) { + try { + val m = JSONArray(msgFile.readText()) + val msgs = ArrayList(m.length()) + for (i in 0 until m.length()) { + val o = m.getJSONObject(i) + msgs.add( + WechatMessage( + talker = o.optString("talker"), + content = o.optString("content"), + createTime = o.optLong("createTime"), + isSend = o.optInt("isSend") == 1, + ), + ) + } + repo.wechatMessages.clear() + repo.wechatMessages.insertAll(msgs) + Log.i(TAG, "微信聊天导入 ${msgs.size} 条") + } catch (t: Throwable) { + Log.w(TAG, "msg import failed: ${t.message}") + } + } + + // ---- Pass 1.6: 朋友圈导入 ---- + val snsFile = File(dir, "moments.json") + if (snsFile.exists()) { + try { + val a = JSONArray(snsFile.readText()) + val ms = ArrayList(a.length()) + for (i in 0 until a.length()) { + val o = a.getJSONObject(i) + ms.add(Moment(userName = o.optString("userName"), text = o.optString("text"), createTime = o.optLong("createTime"))) + } + repo.moments.clear() + repo.moments.insertAll(ms) + Log.i(TAG, "朋友圈导入 ${ms.size} 条") + } catch (t: Throwable) { + Log.w(TAG, "moments import failed: ${t.message}") + } + } + // ---- Pass 2: 人脸锚点(仅 hasFace 且未建锚点)---- val engine = FaceEngine.load(applicationContext) var anchored = 0 diff --git a/server/services/wechat/extract-wechat-chat-moments.py b/server/services/wechat/extract-wechat-chat-moments.py new file mode 100644 index 0000000..7ff2e5a --- /dev/null +++ b/server/services/wechat/extract-wechat-chat-moments.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +微信聊天 + 朋友圈提取 —— 给 lifelog/photolog 人脉档案页用(里程碑 2/4)。 + +- 聊天:EnMicroMsg.db(SQLCipher,key e03ce81 + compat=1 + page1024)的 message 表, + type=1 文本、talker 非 @chatroom → messages.json [{talker,isSend,createTime,content}]。 + 注意 sqlcipher .mode json 输出开头有 PRAGMA 的 "ok",要 raw[raw.index('['):] 跳过, + 否则 app 端 JSONArray 解析失败、消息导不进去(实测踩过)。 +- 朋友圈:SnsMicroMsg.db **是明文 SQLite**(不加密!)的 SnsInfo 表,content 是 protobuf, + 正文用「抽含中文最多的可读段」近似提取 → moments.json [{userName,createTime,text}]。 + +产物 push 到设备 filesDir/wx_enroll/{messages.json,moments.json},app 内「从微信导入联系人」 +的 WeChatEnrollWorker 会 clear+重灌进 wechat_messages / moments 表,按 wxid 关联 Person。 + +用法:python3 extract-wechat-chat-moments.py /tmp/EnMicroMsg.db /tmp/SnsMicroMsg.db e03ce81 ./out +""" +import sys, os, re, json, subprocess + + +def sqlcipher_json(db, key, sql): + script = f'PRAGMA key="{key}";\nPRAGMA cipher_compatibility=1;\nPRAGMA cipher_page_size=1024;\n.mode json\n{sql}\n' + out = subprocess.run(['sqlcipher', db], input=script, capture_output=True, text=True).stdout + return json.loads(out[out.index('['):]) # 跳过 PRAGMA 的 "ok" + + +def extract_messages(en_db, key): + return sqlcipher_json(en_db, key, """ + SELECT talker, isSend, createTime, content FROM message + WHERE type=1 AND talker NOT LIKE '%@chatroom' AND content IS NOT NULL AND content != '' + ORDER BY createTime ASC;""") + + +def moment_text(blob): + if not blob: + return '' + s = blob.decode('utf-8', 'ignore') + segs = re.findall(r'[一-鿿A-Za-z0-9,。!?、,.!?#@::…~()()【】\-—\s]{2,}', s) + + def score(x): + x = x.strip() + if x.startswith(('http', 'wxid', 'mmsns', 'mp.weixin')): + return -1 + if re.fullmatch(r'[A-Za-z0-9]+', x): + return -1 + return len(re.findall(r'[一-鿿]', x)) + best = max(segs, key=score, default='') + return best.strip() if score(best) > 0 else '' + + +def extract_moments(sns_db): + import sqlite3 # SnsMicroMsg 是明文 + c = sqlite3.connect(sns_db) + out = [] + for u, t, content in c.execute("SELECT userName, createTime, content FROM SnsInfo WHERE content IS NOT NULL"): + txt = moment_text(content) + if txt: + ct = int(t) + ct = ct * 1000 if ct < 1e12 else ct # 秒→毫秒 + out.append({'userName': u, 'createTime': ct, 'text': txt}) + return out + + +def main(): + en_db, sns_db, key, out_dir = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] + os.makedirs(out_dir, exist_ok=True) + msgs = extract_messages(en_db, key) + json.dump(msgs, open(os.path.join(out_dir, 'messages.json'), 'w'), ensure_ascii=False) + moments = extract_moments(sns_db) + json.dump(moments, open(os.path.join(out_dir, 'moments.json'), 'w'), ensure_ascii=False) + print(f'聊天 {len(msgs)} 条,朋友圈 {len(moments)} 条') + + +if __name__ == '__main__': + main()