Compare commits

...
146 Commits
Author SHA1 Message Date
Wxw-Gu a80624d6ab chore: package 2026-08-12 10:06:50 +08:00
Wxw-Gu cf3f115124 feat: 完成 TraceMemo v2.2.0 品牌身份与数据迁移升级
- 将 WechatExplorer 产品身份升级为 TraceMemo
- 更新 appId、Runtime Identity、Reader Skill 和 API 环境变量
- 增加旧用户数据、Knowledge、Token 与 AI Provider 安全迁移
- 保留 Windows WeFlow 和旧版配置兼容
- 完善首次启动迁移测试及 v2.2.0 发布文档
- 移除 macOS Intel x64 构建与发布支持
2026-08-11 17:52:57 +08:00
Wxw-Gu 0c1d859e1b chore: 补充 2026-08-11 15:59:35 +08:00
Wxw-Gu 2482c23c5e Merge branch 'develop' into feat/tracememo-v2.2.0 2026-08-11 14:44:58 +08:00
qingmaoandGitHub 0706ba13e6 Merge pull request #15 from Michael-py001/develop
fix: 修复日报生成时群成员名称显示错误
docs: 添加本地启动排障文档
chore: 添加electron下载源配置
2026-08-11 14:43:51 +08:00
Wxw-Gu 55c6fb8cd3 feat: 完成 TraceMemo v2.2.0 品牌升级并保留旧数据兼容
- 将用户可见品牌升级为 TraceMemo(迹忆)
- 增加最早期 userData/sessionData 兼容路径选择
- 保留 WechatExplorer runtime identity 以兼容 safeStorage
- 继续使用旧 Knowledge、Settings、API Token 和 Provider 配置
- 新日志写入 TraceMemo 目录并保留历史日志
- 保留旧 API、Skill、环境变量和导出目录兼容标识
- 更新相关文档、界面文案与自动化测试
2026-08-11 14:42:12 +08:00
wushili b6901c9d0c Merge branch 'develop' of https://github.com/Wxw-Gu/WechatExplorer into develop 2026-08-11 13:13:13 +08:00
wushili 4d95fd7650 fix: 修复日报生成时群成员名称显示错误 2026-08-11 13:12:22 +08:00
wushili 775b5aff18 Merge branch 'develop' of https://github.com/Wxw-Gu/WechatExplorer into develop 2026-08-11 11:23:45 +08:00
wushili 2f2f682fa0 docs: 添加本地启动排障文档 & 添加electron下载源配置 2026-08-11 11:23:29 +08:00
Wxw-Gu 2354fd0766 feat: 增加退出选择任务栏 2026-08-11 11:22:45 +08:00
Wxw-Gu 6192e7cd35 feat: 完善群聊日报语音转写缓存
支持空内容的微信语音消息并兼容历史缓存转写结果
将系统通知标记为微信系统消息,排除活跃成员与发言排行
2026-08-11 11:08:55 +08:00
Wxw-Gu 68f0c0b5a3 Merge branch 'main' into develop 2026-08-11 09:55:37 +08:00
电摇小子 7a5499f093 feat: 完善日报语音转写与全部聊天分目录导出 2026-08-11 02:59:54 +08:00
电摇小子 d1a090bb6b chore: 修改windows打包报错 2026-08-11 02:59:43 +08:00
电摇小子 8a0d3b02d9 fix: 完善交互、知识库目录与 Windows 运行库提示 2026-08-11 02:59:30 +08:00
Wxw-Gu c41675b809 docs: 更新文档 2026-08-10 10:44:48 +08:00
电摇小子 4cd3ea0bc0 docs: 更新文档 2026-08-09 19:03:34 +08:00
电摇小子 32864ff88c docs: 更新文档 2026-08-09 10:12:18 +08:00
Nanin 291c82f0e2 修复bug,第一次导出缩略图,后面有了高清图后再次导出应该覆盖
按原图、中图和缩略图对本地图片资源分级,始终优先选择高清变体。

增量导出仅复用满足清晰度要求的图片,重新探测旧缩略图并在高清图出现后替换;仅缩略图降级时显示提示。
2026-08-08 20:53:40 +08:00
Nanin 666d8896ee 修复bug,针对部分没有MD5名字的视频也能支持导出 2026-08-08 19:41:14 +08:00
电摇小子 f2c58f39b0 docs: 更新文档 2026-08-08 14:54:54 +08:00
Wxw-Gu cd2c3cfaee docs: 更新文档 2026-08-07 22:52:56 +08:00
Wxw-Gu a73af3b5ad feat: 为本地 HTTP API 增加 Token 鉴权与安全加固
- 使用 Electron safeStorage 加密存储并自动初始化 API Token
- 为 health 以外的接口增加 Bearer Token 鉴权
- 限制 CORS 仅允许可信本地 Origin
- 增加鉴权、Token rotation、safeStorage 和手动验收测试
2026-08-07 17:48:05 +08:00
Wxw-Gu 0c21008ec3 Merge branch 'main' into develop 2026-08-07 16:21:48 +08:00
Wxw-Gu 96c67f5bf8 fix: 修复公众号消息读取与 Windows 中文路径兼容
- 支持从 biz_message 分片读取公众号聊天记录
- 在左侧栏增加独立的公众号折叠分组
- 为 Windows 中文数据目录建立 ASCII 路径桥接 (#12)
- 补充公众号分片、侧栏分类和路径桥接测试
2026-08-07 16:21:39 +08:00
Wxw-Gu 0b845db2e0 feat: 优化问问微信检索性能与分析交互
- 补充 Worker、WCDB、sender、IPC、序列化时间账
- 增加 Agent 增量覆盖统计和重复检索停止条件
- 补充性能与交互回归测试
2026-08-07 15:28:45 +08:00
Wxw-Gu c125e85ffc chore: 更新二维码 2026-08-07 14:43:23 +08:00
Wxw-Gu 43654bf0e2 Merge remote-tracking branch 'origin/nanin/develop' into develop 2026-08-07 09:45:35 +08:00
电摇小子 e43af6f1fe feat: 完善首次连接账号身份展示
首次连接前读取当前账号昵称和头像
从账号目录安全推导其他账号 wxid 避免账号串号
2026-08-07 01:39:11 +08:00
电摇小子 3af62783dd feat: 完善日报图片与公众号消息展示
修复日报图片读取格式与模型不支持时的友好降级
恢复话题关键词回退,确保词云在缺少顶层关键词时仍可展示
支持公众号多文章消息在聊天、日报、检索和 HTML 导出中完整呈现
迁移账号身份缓存并优化首次连接的账号占位信息
补充图片、词云、账号缓存与多文章消息回归测试
2026-08-07 00:00:52 +08:00
Nanin 88a6d750fb fix: 优化移动端导出消息布局与计数
- 调整移动端消息栈宽度,统一约束来源标签与消息内容\n- 精简档案计数文案,移除虚拟窗口已显示数量\n- 补充搜索前后消息保持单行展示的移动端回归测试
2026-08-06 21:32:17 +08:00
电摇小子 0ec2e6a0be feat: 知识库更新,优化批量语音转写 - 合并索引 - 增加会话级批量转写处理 - 补充语音转写回归测试 2026-08-06 20:31:08 +08:00
电摇小子 a0e8ab278f feat: 收敛 AI Search 检索边界与问答体验 2026-08-06 20:29:48 +08:00
电摇小子 ad4b3a8074 test: 暂存代码 2026-08-06 20:29:25 +08:00
Nanin 307d247660 fix: 优化导出档案系统消息宽度
- 系统消息气泡按内容自然展开,减少短消息不必要换行
- 保留容器最大宽度约束,避免窄屏横向溢出
- 增加撤回消息桌面端单行显示回归测试
2026-08-06 00:23:22 +08:00
Nanin e3615c0153 feat: 优化聊天导出进度与媒体处理
1. 拆分读取、解析、转写、媒体处理、写入和压缩阶段,完善任务进度展示。

2. 增量导出复用语音资源与转写结果,并为缺失转写补充识别。

3. 稳定远程头像文件名并保留同源头像版本更新。

4. 支持音频附件直接播放、新窗口打开附件,并解码分享标题 XML 实体。

5. 补充导出进度、语音、头像、附件和消息解析回归测试。
2026-08-05 23:22:47 +08:00
Wxw-Gu 3c59fb64e9 Merge branch 'nanin/develop' into develop 2026-08-05 18:44:25 +08:00
Nanin c4e13ee7d2 fix: 修复聊天档案搜索误匹配微信内部 ID
- 移除隐藏 senderId 搜索字段,避免 xi 等关键词误命中 wxid
- 保留发送者昵称、消息正文及结构化可见内容的搜索能力
- 新增 wxid 与可见正文匹配的回归测试
2026-08-05 18:19:55 +08:00
Nanin 12cae061df feat: 优化聊天档案移动端与账号切换体验
- 限制移动端横向滚动并修复发送消息头像裁切
- 调整顶部搜索、筛选与独立计数布局,七类筛选保持单行
- 移除聊天选项消息数和顶部更新时间,简化单聊天展示
- 使用头像、名称、切换图标与自定义弹层替代原生账号下拉控件
- 补充桌面及移动端的布局、切换和无横向溢出回归测试
2026-08-05 18:03:25 +08:00
majun.jason 17cc99de37 feat: 优化聊天档案搜索体验
- 高亮展示搜索结果中的命中词,覆盖普通文本与结构化消息内容
- 为全部及分类搜索结果提供聊天定位,定位后清空搜索并回到完整消息上下文
- 修复移动端搜索框聚焦自动放大,并补充桌面与移动端测试覆盖
2026-08-05 15:24:36 +08:00
majun.jason 933a87ebbb feat: 优化聊天档案浏览与增量导出
1. 增加档案加载状态、错误提示和延迟数据加载。
2. 优化移动端工具栏、消息布局及横向滚动控制。
3. 支持按年份折叠时间轴,并同步可见月份定位。
4. 使用自定义文件名作为档案标题。
5. 增量导出时保留历史头像,仅在视觉变化后新增头像版本。
6. 修复附件消息被误判为合并转发的问题。
7. 补充单元、集成及端到端测试覆盖。
2026-08-05 15:00:31 +08:00
Wxw-Gu 55da2e2e67 fix: 语音模型下载 2026-08-05 10:21:13 +08:00
电摇小子andWxw-Gu c2f9d352db feat: 导出html页面 2026-08-05 09:46:10 +08:00
电摇小子andWxw-Gu 7529a67f09 feat: 语音 2026-08-05 09:45:59 +08:00
majun.jason 4e84b52cc4 feat: 完善聊天导出与档案浏览
1. 修复数据量较大时,历史数据可能无法完整导出的问题
2. 优化分享 Tab 的信息展示,支持小程序、链接、地图等消息
3. 优化系统 Tab 的信息展示,支持红包、转账、拍一拍、撤回等消息
4. 修复默认进入时,时间轴不随消息自动定位的问题
5. 增加“定位到聊天位置”功能
2026-08-05 00:14:28 +08:00
majun.jason 66a6ee3e32 fix: 修复视频定位与运行时依赖打包 2026-08-04 21:00:13 +08:00
电摇小子 69bc6f57e7 fix: test 2026-08-04 20:41:06 +08:00
电摇小子 894281fb44 fix:test 2026-08-04 20:32:50 +08:00
电摇小子 2f6ab7b773 fix: 修复跨平台导出测试与 Playwright 浏览器安装 2026-08-04 20:26:04 +08:00
qingmaoandGitHub a0e8be0cdf Merge pull request #9 from nanin/nanin/develop
feat: 支持多聊天合并导出
2026-08-04 20:18:44 +08:00
majun.jason e153ddb794 fix: 修复 macOS 端到端测试 2026-08-04 20:13:07 +08:00
majun.jason 60c501e148 feat: 支持多聊天合并导出 2026-08-04 19:26:18 +08:00
Wxw-Gu c23ed23bd2 chore: 修改打包配置 2026-08-04 17:36:14 +08:00
Wxw-Gu 0a3d930298 fix: 修复媒体导出与 HTML 聊天档案体验
- 修复打包版图片、语音解析不可用,内置 FFmpeg 与必要运行时依赖
- 修复 HTML 导出原图查找、缩略图回退及增量档案媒体解析问题
- 修复本人昵称在软件和 HTML 导出中显示为微信号的问题,并兼容旧档案昵称迁移
- 修复 HTML 语音播放器超出消息气泡的问题
- 优化 HTML 双向滚动加载,大量消息时最多渲染 240 条,避免页面卡顿
- 移除图片解密页面中手动配置 FFmpeg 的相关提示
- 补充图片解密、语音运行时、打包资源和 HTML 导出相关测试
2026-08-04 17:03:07 +08:00
Wxw-Gu c6587c517a feat: 增强 HTML 聊天档案导出
- 增加时间轴、消息筛选、搜索和完整时间显示
- 使用窗口化懒加载优化大消息档案
- 支持同名档案增量合并并安全复用媒体资源
2026-08-04 12:29:38 +08:00
Wxw-Gu c70e49bf16 fix: 完善聊天解析与导出体验
- 修复引用消息名称和图片布局
- 明确单会话图片测试日志范围
- 支持导出文件附件
- 完善图片批测、会话刷新和安全退出
2026-08-04 12:03:07 +08:00
Wxw-Gu d76727875d chore: 提升版本 2026-08-03 14:12:00 +08:00
Wxw-Gu 08e1294e5d feat: 完善多账号连接诊断与聊天媒体导出
- 新增微信账号发现、环境诊断和分步数据库连接引导
- 支持按账号安全保存数据库密钥及快速切换账号
- 完善 WCDB 历史消息分片读取和分页状态提示
- 支持导出图片、视频和语音,提供原图优先及缩略图回退
- 更新安装指引、兼容版本说明和相关自动化测试
2026-08-03 10:43:14 +08:00
电摇小子andWxw-Gu 224308f0e0 fix: 修复 macOS 会话昵称显示
(cherry picked from commit be092c64e528cfc363f0ddd32c480eea5532799f)
2026-08-03 10:08:59 +08:00
电摇小子andWxw-Gu 3e57a8432d test: 建立桌面端自动化回归测试体系并完善跨平台 CI
(cherry picked from commit 74267ae2f63f8256c3da84b04f5b330e6e9d4c67)
2026-08-03 10:08:28 +08:00
电摇小子andWxw-Gu b4f909a597 fix: 修复语音首播并完善消息解析与会话兼容性
(cherry picked from commit 214090192f5dfc91cdec0269bad434d3e22394d8)
2026-08-03 10:04:45 +08:00
电摇小子andWxw-Gu ee7dc11e92 fix: 优化数据库启动与设置页响应性能
(cherry picked from commit f6614548b0e742bc703b2a5aaa5060720985ed27)
2026-08-03 10:04:19 +08:00
电摇小子andWxw-Gu 90bf1aed90 fix: 支持 wxgf 原图解密并修复缩略图缓存刷新
- 增加 wxgf/HEVC 图片转换支持
- 增加 FFmpeg 跨平台安装、目录填写与能力检测
- 避免缩略图占用原图缓存,下载原图后可即时刷新
- 移除聊天图片的缩略图角标

(cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30)
2026-08-03 10:03:55 +08:00
电摇小子andWxw-Gu a3955d691d fix: 优化 Windows 启动性能与聊天图片缓存
- 增加聊天图片磁盘持久化缓存,重启后直接复用
- 将 DAT 图片解密移至 Worker,避免阻塞主进程
- 增加图片加载优先级和并发控制
- 优化会话目录与图片文件的异步查找
- 使用本地媒体协议加载缓存图片,减少 Base64 IPC 开销
- 优化启动缓存与数据库初始化流程,降低窗口未响应时间

(cherry picked from commit 82bcc8d32ca674de38c745cc9925ed2309887dc7)
2026-08-03 10:03:19 +08:00
Wxw-Gu 8a6d443acc chore: 提升版本 修改打包命令 2026-07-31 11:40:41 +08:00
Wxw-Gu f0601cdc85 feat: 重构README 新增引导功能 2026-07-31 11:26:23 +08:00
Wxw-Gu 77adc744e0 feat: 设置功能 2026-07-30 09:49:56 +08:00
Wxw-Gu 0adb064681 refactor: 代码拆分 2026-07-29 10:50:27 +08:00
Wxw-Gu 8e40487e08 docs: 修改README 2026-07-28 16:50:13 +08:00
Wxw-Gu d0ae9e6019 perf: 优化图片消息后台加载与解密缓存
- 缩略图优先展示并在后台准备原图
- 增加图片请求去重与受控并发队列
- 缓存图片路径、账号目录和解密结果
2026-07-28 16:11:59 +08:00
Wxw-Gu 49684f3365 feat: 完善 AI 智能检索与定位
新增 AI 查询规划和主题变体多轮检索
修复无结果时回退全量消息导致的错误结论
优化目标成员优先级和大数据量消息匹配性能
新增检索诊断日志、任务中心和持久化缓存
支持证据按时间定位档案并闪烁提示
2026-07-28 15:52:31 +08:00
Wxw-Gu 33aaf4d558 feat: 修改缓存加载逻辑 2026-07-28 09:56:50 +08:00
电摇小子 5ccabb3898 feat: 完善微信小程序与红包消息解析
- 新增小程序和红包结构化消息及专用卡片
- 修复嵌套类型、拍一拍和表情包误判
- 补充常见 AppMsg 类型分类
2026-07-28 03:20:11 +08:00
电摇小子 7db845ac7e feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示
- 使用持久化缓存加速启动并异步读取 WCDB 消息
- 修复空群缓存、头像和群成员名称丢失问题
- 修复引用图片缩略图、虚拟卸载缓存和图片预览
- 修正引用消息发送者显示为群 ID 的问题
2026-07-28 02:45:20 +08:00
电摇小子 9348c5ce4a feat: 修复导出任务状态并优化聊天记录导出体验 2026-07-27 22:06:08 +08:00
Wxw-Gu f626d89a1a feat: 提交 2026-07-27 20:34:57 +08:00
Wxw-Gu 95e517eca5 feat: 表情包优化 2026-07-27 17:55:11 +08:00
Wxw-Gu e93e4554d7 feat: 导出功能 2026-07-27 17:43:36 +08:00
Wxw-Gu 9ae0c6cc47 chore: 删除无用代码 2026-07-27 10:13:13 +08:00
Wxw-Gu 59e609b08a Merge branch 'develop' 2026-07-27 09:42:25 +08:00
Wxw-Gu 9de7dcde3b chore: 提升版本号 2026-07-27 09:41:49 +08:00
电摇小子 cb3c2855c0 fix(paths): 同步 imageKeyRoot 与 dbRoot,仅识别 xwechat_files
之前的状态面板(chat.getCurrentAccountRoot())和自动获取图片密钥(settings.imageKeyRoot)
走两个完全不同的来源,二者漂移导致状态显示 D 盘但实际扫描到 C 盘。

index.ts:
- key:autoGetImageKey 优先级调整为 chat.getCurrentAccountRoot() → self.accountRoot →
  settings.imageKeyRoot → settings.dbRoot,让运行时识别的目录永远最优先。
- db:init:保存 dbRoot 时同步更新 imageKeyRoot,避免 settings 缓存漂移。
- db:reopenWithRoot:用户手动切换根目录时同步更新 imageKeyRoot。

settings-store.ts:
- loadSettings:dbRoot 修正时同步 imageKeyRoot;若 imageKeyRoot 指向旧路径或不存在,
  回退到 dbRoot。
- 新增 redirectLegacyWeChatFilesToXwechat():当 settings 里的 dbRoot/imageKeyRoot 仍指向
  V3 时代的 "Documents\WeChat Files" 路径时,自动重定向到同目录下的 xwechat_files(如果存在)。
  老用户升级时无需手动迁移。

wcdb4-client / windows-db-root-discovery / key-service-win:
- 候选目录列表只保留 xwechat_files 形态(Documents\xwechat_files、
  AppData\Roaming\Tencent\xwechat_files、macOS 的 Library/Containers/.../xwechat_files),
  移除 V3 时代的 "WeChat Files" 候选。
- DB_ROOT_NAMES 只包含 xwechat_files,自动扫描也只认 V4 路径。

V3 数据(V1 头 dat / WeChat Files 目录结构)如果用户机器上还存在,仅作为残留,
不会被 WechatExplorer 当成有效数据源。
2026-07-26 20:46:51 +08:00
电摇小子 dca88e5db3 fix(image-decrypt): 仅支持 WeChat 4.0,移除 V3 兜底 + 模板诊断
WeChat 4.0 dat 文件头为 07 08 56 32 08 07。V3 / 老版本(V1 头)不在支持范围。

image-decrypt-service:
- 移除 defaultV1AesKey ('cfcd208495d565ef') 字段。
- 移除 if (version === 1) 的 V1 默认 key 分支。
- getDatVersion 只识别 V2 头;其他返回 0 走 unsupported。
- 注释说明 decryptDatV4 方法名里的 V4 是历史命名,跟协议版本无关。

key-service-win._findTemplateData / autoGetImageKeyByMemoryScan:
- 统计扫描诊断信息:搜索到的 _t.dat 数量、V2 头数量、非 V2 数量、扫描根目录。
- 把模糊的「未找到 V2 模板文件」拆成三种具体提示:
  1) 目录下完全没有 _t.dat → 让用户先在微信里点开图片大图(缩略图未生成)。
  2) 有 _t.dat 但都不是 V2 头 → 让用户查看更多图片。
  3) 有 V2 但没有有效长度 → 让用户查看更多图片。
- 每条都附带实际扫描根目录,便于远程排查时一眼看出路径是否正确。
2026-07-26 20:46:34 +08:00
电摇小子 1a9f27488b refactor(image-decrypt): 设置面板 UI 重构 + 测试步骤联动
页面拆分两个目录语义:
- 上方「图片解密状态」的「图片资源目录」始终等于 chat 实时识别的默认目录(chat.getCurrentAccountRoot()),
  不再被用户输入覆盖。
- 下方「图片密钥管理」移除「图片资源目录」输入框,只保留 XOR / AES 两个 Key。
  该字段之前会被保存但实际不影响图片解密(数据库查询走默认根目录),保留只会让用户困惑。

UI 改动:
- ImageKeyConfiguration:移除 resourceRoot 输入框,edit() 签名收窄为 xorKey | aesKey。
- ImageTestSection:测试结果由 3 列横排改为步骤条样式(pending / ok / fail / skipped),
  任意一步 ✗ 后续步骤自动标记「跳过」并灰掉,前端语义上变成「要么都成功要么都失败」。
- AutoDetectImageKeySection:加一行小字提示「仅支持 WeChat 4.0,V3 及以下无法解析」。

后端改动:
- testImageDecryption:解密失败时把 fileFound/decrypted/readable 统一置 false,
  拆出「解密成功但不可读」中间态,让 UI 步骤联动准确反映。
- inspectImageDecryptionStatus:accountRoot 兜底改为 getCurrentAccountRoot() || config.resourceRoot,
  让状态面板始终显示当前识别到的默认目录。
- sanitizeImageError:把 'no_image_message' / '300' / 'unsupported' / 'dat version' 关键字翻译成具体提示。
- useImageDecryptionController.autoDetect:错误信息原文透传,不再走 sanitizeImageError(后者会
  把扫描阶段的「未找到 V2 模板文件」/「60 秒未找到 AES 密钥」等归类成「无法解析媒体文件」,掩盖真因)。
- image-key-config-service.save():不再写回 imageKeyRoot,下方输入框只用于校验,不再落盘。
2026-07-26 20:46:20 +08:00
电摇小子 3b64e18b5e chore(security): 移除 AI 内置 Key fallback + 修正默认模型名
发布版本不再自动从环境变量读取 VITE_DEEPSEEK_API_KEY 创建默认 DeepSeek provider。
ensureEnvironmentMigration() 改为 no-op(保留方法作为占位)。
用户首次启动必须在「设置 → AI 模型」手动配置 API Key。

migrateLegacy() 保留:用于把用户自己之前存在 localStorage 的旧配置迁移到加密存储,
与内置 Key 是两回事。

附带修正 .env.example 默认模型名:deepseek-v4-flash 是已弃用/不存在的标识符,
DeepSeek 官方未发布此模型,统一改为 deepseek-chat。
2026-07-26 20:45:53 +08:00
Wxw-Gu 554dccdb21 Merge branch 'develop' 2026-07-24 16:36:11 +08:00
Wxw-Gu c674dcbc4a docs: 更新文档 2026-07-24 16:19:27 +08:00
Wxw-Gu 0f65b96da1 docs: 修改二维码 2026-07-24 14:46:17 +08:00
Wxw-Gu 8488e81bb5 feat: 支持微信视频消息解析与播放
支持视频 XML 解析、本地文件映射与拖动播放。

修复源码乱码注释并统一 UTF-8 编码。
2026-07-24 14:16:34 +08:00
Wxw-Gu 430a36333b docs: 修改Readme 2026-07-24 11:28:10 +08:00
Wxw-Gu 474250c6c7 feat: 添加微信消息防撤回功能 2026-07-24 11:27:44 +08:00
Wxw-Gu 794880d389 chore: 提升版本 2026-07-23 16:25:08 +08:00
Wxw-Gu d0ceeceb68 fix: 容错修复群聊日报 JSON, 图片样式问题 2026-07-23 16:20:45 +08:00
Wxw-Gu 62f729d281 chore: 添加应用诊断日志 2026-07-23 16:09:20 +08:00
Wxw-Gu 0d544275c0 chore: 删除图片 2026-07-17 14:46:11 +08:00
Wxw-Gu a91b4d7a56 docs: 添加交流二维码并升级版本 2026-07-17 14:44:50 +08:00
Wxw-Gu f9b567fba2 feat: 重构数据库登录与路径发现 2026-07-17 14:35:53 +08:00
Wxw-Gu 1f693aa3d7 perf: 优化聊天渲染与后台任务性能 2026-07-17 11:37:02 +08:00
Wxw-Gu 8e1e166856 merge: 合并日报名称与图片识别修复 2026-07-15 21:18:02 +08:00
Wxw-Gu 1275c512c4 fix: 修复日报成员名称与图片识别兼容性 2026-07-15 21:17:54 +08:00
Wxw-Gu 1abaa57a0c merge: 合并 AI 微信 Agent 能力 2026-07-15 20:50:04 +08:00
Wxw-Gu d08db46479 feat: 完善跨平台发布与生产密钥隔离 2026-07-15 20:49:47 +08:00
Wxw-Gu eaea8d8435 feat: 内置微信连接器并完善 Agent 查询能力 2026-07-15 20:27:05 +08:00
电摇小子andWxw-Gu 1e97953d67 feat: 集成 Agent Hub 微信机器人能力 2026-07-15 17:26:15 +08:00
Wxw-Gu 597667e005 merge: 合并群聊日报与图片理解功能 2026-07-15 16:32:35 +08:00
Wxw-Gu 515348b6d8 feat: 完善群聊日报模板与图片理解 2026-07-15 16:29:25 +08:00
Wxw-Gu e150605c91 merge: feat/report 集成进 feat/newReport (M1)
合并 feat/report 的日报功能 + main 的图片识别基建:
- 保留 main 分支的图片识别能力(ai-provider-service + key-store + settings/ai-model)
- 保留 feat/report 的日报功能(ChatWindow.tsx + group-report* + mobile_daily_report.html)
- index.ts/preload 同时保留两边的 IPC handler(api:chat / ai:testVision / report:export / db:getImage)

冲突解决:
- main/index.ts ai:chat:采用 main 版本(AIProviderService)
- group-report-service.ts values:采用 feat/report 版本(完整 hero/section 占位符)
- 整文件替换:ChatWindow.tsx / group-report.ts / mobile_daily_report.html
  (采用 feat/report 版本,与 group-report-facts.ts 协同)
2026-07-15 10:34:19 +08:00
电摇小子 556d70eab3 feat: 临时加一个windows 目录输入框 2026-07-14 14:33:40 +08:00
电摇小子 9bd2c4bb94 chore: 提升版本号 2026-07-14 11:10:42 +08:00
电摇小子 19128fccca docs: 修改README 2026-07-14 11:07:35 +08:00
电摇小子 eaf8e9b07d fix: 修复登录报错key问题 2026-07-14 11:07:35 +08:00
电摇小子 aebf56b3f7 feat: 统一应用品牌图标并修复 macOS 打包路径 2026-07-14 11:07:35 +08:00
电摇小子 ce4b00bcd9 实现 AI 图片理解能力测试 2026-07-14 11:07:35 +08:00
电摇小子 31df97237d 重构 AI 模型配置中心 2026-07-14 11:07:35 +08:00
电摇小子 7b54b611d3 实现图片解密设置页 2026-07-14 11:07:35 +08:00
电摇小子 f1ceef0e5e 实现数据库密钥设置页 2026-07-14 11:07:35 +08:00
电摇小子 ab24185670 精简账号与数据库设置页 2026-07-14 11:07:35 +08:00
电摇小子 842144eba0 fix: 修改登录报错, error改为warn 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d37341ab52 feat: 设置页面新UI 未做完 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 5d041aa137 feat: 完成本地 API 中心与 Reader Skill 流程 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 519c10223d fix: 完善 AI 日报详情状态与解析容错 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d28b579cb4 feat: 优化 AI 日报结果中心信息架构 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 c291c94bd8 feat: 实现 AI 日报历史资产化 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 16a9aed6bb 重构 UI-04 AI 群聊日报工作区 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 7880e874b2 完成 UI-02 和 UI-03 聊天档案界面 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 7e217f7034 增加自动登录环境开关 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 4f0b4884b6 完成 UI-01 应用外壳与设计令牌 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d4f6b755e5 修复 Windows 消息游标回退查询 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 995a95e8dc 修复 Windows 会话名称和密钥显示图标 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 8bb9576f10 feat: 支持点击图片优先打开原图 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 a776830144 feat: 优化 Windows 缓存加载和图片显示 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 ad50939005 feat: 支持微信内置表情渲染 2026-07-14 10:20:45 +08:00
电摇小子and电摇小子 71e970c55c feat: 支持图片密钥配置与内存扫描 2026-07-14 10:20:23 +08:00
电摇小子and电摇小子 8d82fabf57 docs: 更新 Windows 支持说明与 SIP 教程 2026-07-14 10:20:00 +08:00
电摇小子and电摇小子 e8c12696b2 fix: 优化群聊展示与群日报生成 2026-07-14 10:19:40 +08:00
电摇小子and电摇小子 267730d59e feat: 支持 Windows WCDB 解密与打包 2026-07-14 10:15:36 +08:00
电摇小子 3dbc11c19d fix: gitignore 2026-07-14 10:12:05 +08:00
电摇小子 d079d45279 feat: 更换日报功能模板
- ChatWindow 模型下拉框新增 deepseek-v4-pro /
  deepseek-v4-flash
  - 主进程 model 兜底值改为 deepseek-v4-flash
  - 同步更新 .env.example 默认模型
  - 修复旧 localStorage 中 gpt-5.5 等不支持的模型导致的
  400 报错
2026-07-10 15:40:35 +08:00
电摇小子 b39cb362f7 Merge branch 'fix/xkey' 2026-07-10 10:38:39 +08:00
电摇小子 f9e629948d Merge branch 'feat/local-http-api' 2026-07-09 10:37:20 +08:00
电摇小子 a0b163823d fix: 修复登录 2026-07-09 10:37:11 +08:00
电摇小子 3eb4438f4c feat: 群日报 9 宫格头像自动反推 + 持续时长语义 + 自动登录
- group-report-service: enrichAvatarsFromGroup 从群成员快照反推真头像
  - group-report-service: 修 SVG data URL 正则,fallback 现在能正常嵌入
  - group-report (shared): GroupReportMetadata/Result 加 talker/warnings 字段
  - timeSpan 改为持续时长(\"1 h\" / \"30 min\" / \"2 d\" 紧凑半角)
  - App.tsx 启动自动连接(env var + safeStorage)
  - Wcdb4Client 父目录自动解析为最新 wxid
  - HTTP server EADDRINUSE 友好提示 + 指数退避
  - installSafeConsole 修 EPIPE crash
  - SettingsPanel 测试连接后自动更新 dbRoot
2026-07-08 16:40:03 +08:00
电摇小子 7932ea2f54 feat: 本地 HTTP API + 设置面板 + 账号自助入口
让 WechatExplorer 既能用图形界面浏览聊天记录,也能作为本机 MCP 数据源被
Claude / Codex 等客户端通过 127.0.0.1:6131 直接拉取。

本机 HTTP API
  - 新增 http-server: 8 个端点,覆盖 health / current_time / contact /
    chatroom / recent_chat / chatlog / group_snapshot / resolve / report
  - 时间参数支持 YYYY-MM-DD / YYYY-MM-DD/HH:mm / Unix 秒级;日期单独使用
    时自动补到 00:00:00~23:59:59,避免漏消息
  - apiServer 单例支持动态启停,启动失败返回 friendlyMessage(把
    EADDRINUSE 翻译成"端口已被占用"的中文错误并附 4 次重试)

数据库根目录与自服务入口
  - Wcdb4Client 接受 accountRoot 时会自动解析:父目录下找最新含
    db_storage 的 wxid 子目录;设置面板"测试连接"成功后回写解析后的
    精确路径
  - 抽 chat-service.ts:IPC 和 HTTP 共享 listContacts / listMessages /
    getGroupSnapshot / searchMessages / getSelfAccountInfo / testConnection
    / reopenWithRoot
  - WechatDb 接受可选 accountRoot;设置面板新增"应用并重新初始化"
    按钮,改完 dbRoot 立即生效
  - 新增 settings-store.ts,dbRoot / apiEnabled / apiHost / apiPort 落到
    userData/settings.json

主进程健壮性
  - 新增 safe-log.ts 包一层 console.log/warn/error,electron-vite 关闭
    子进程 stderr 后写 EPIPE 不再炸 IPC handler(原 main build 启动时
    即 installSafeConsole)
2026-07-07 13:58:10 +08:00
电摇小子 4d14ce867c docs: README 2026-07-07 13:52:47 +08:00
电摇小子 7f7d673abb chore: 修改打包名推送 2026-07-03 14:11:49 +08:00
459 changed files with 88036 additions and 3646 deletions
+12 -1
View File
@@ -1,7 +1,14 @@
# WeChat Database Key (Optional, can be entered in UI)
VITE_DB_KEY=
# Auto login on startup with VITE_DB_KEY or saved key.
# Set to true/1/yes/on for local development. Default is disabled.
VITE_AUTO_LOGIN=false
# AI API Configuration (Optional, can be entered in UI)
# 注意:发布版本不再自动读取以下环境变量。
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。
VITE_DEEPSEEK_API_KEY=
VITE_AI_BASE_URL=https://api.deepseek.com
VITE_AI_MODEL=deepseek-chat
@@ -10,8 +17,12 @@ VITE_AI_MODEL=deepseek-chat
VITE_FILTER_MSG_TYPES=
# Image Decryption Keys (Optional, for WeChat 4.0+ image decryption)
# These are used to decrypt image .dat files in WeChat 4.0+
# These are dev fallbacks. End users can fill or auto-fetch them in Settings.
# XOR Key: hex format like 0x40, 0x53 etc.
# AES Key: 16-character string, derived from wxid and code
VITE_IMAGE_XOR_KEY=
VITE_IMAGE_AES_KEY=
# Electron E2E test window close delay in milliseconds.
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
WXE_E2E_CLOSE_DELAY_MS=2000
+80
View File
@@ -0,0 +1,80 @@
name: Tests
on:
push:
pull_request:
jobs:
desktop-tests:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 7.33.7
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-go@v5
with:
go-version-file: services/wechat-connector/go.mod
cache-dependency-path: services/wechat-connector/go.sum
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
if: runner.os == 'macOS'
run: pnpm exec playwright install chromium
- name: Type check
run: pnpm typecheck
- name: Unit tests
run: pnpm test:unit
- name: Component tests
run: pnpm test:component
- name: IPC integration tests
run: pnpm test:integration
- name: Skill installation instruction tests
run: pnpm test:skill-install
- name: WeChat connector tests
run: pnpm test:wechat-connector
- name: Build Electron test application
run: pnpm test:e2e:build
- name: Electron E2E tests
run: pnpm exec playwright test --grep-invert="@visual"
env:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Platform visual regression
run: pnpm exec playwright test tests/e2e/visual.spec.ts
env:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Upload Playwright diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.os }}
path: |
test-results/
playwright-report/
if-no-files-found: ignore
retention-days: 14
+13 -1
View File
@@ -1,9 +1,21 @@
node_modules
*.tsbuildinfo
dist
out
docs/
.env
.DS_Store
.eslintcache
*.log*
coverage/
playwright-report/
test-results/
resources/connectors/wechat/
.omc
.codex/
docs/design/
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md
AGENTS.md
findings.md
progress.md
task_plan.md
+1
View File
@@ -1 +1,2 @@
shamefully-hoist=true
electron_mirror=https://npmmirror.com/mirrors/electron/
+1 -1
View File
@@ -6,6 +6,6 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.json-language-features"
}
}
+274 -65
View File
@@ -1,94 +1,303 @@
# WechatExplorer
# TraceMemo(迹忆)
MAC系统 获取微信聊天记录 AI一键生成群聊总结
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
<p align="center">
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
</p>
## 项目说明
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
<p align="center">
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
查看聊天 · 找回信息 · AI 问答 · 群聊日报总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
</p>
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
<p align="center">
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
</p>
## ✨ 功能特性
<p align="center">
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
·
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
·
<a href="./docs/README.md"><b>完整文档</b></a>
</p>
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
- **全局搜索**: 快速搜索聊天内容。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
<p align="center">
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
</p>
## 📸 预览
<p align="center">
<img src="./public/机器人.png" alt="TraceMemo 主界面" />
</p>
### 群聊总结长图
## TraceMemo(迹忆)是什么
<img src="./public/example1.png" alt="总结图片" />
TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。
### 软件主页面
TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
<img src="./public/example2.png" alt="软件主页面" />
## 为什么叫 TraceMemo(迹忆)
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
## 📦 安装说明
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
1. 下载下方的 `xxx.dmg` 文件
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
`系统设置 -> 隐私与安全性 -> 仍要打开`
“迹忆”可以理解为“留下痕迹的记忆”。TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀
## 🚀 快速开始
> **品牌说明**
>
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
### 使用前置要求
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
- **微信版本**:
- 微信 4.0+: 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
- 如无法获取本地数据库密码,则无法使用当前项目
- Node.js (推荐 v16+)
- pnpm@7
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
你可以直接浏览聊天,也可以用自然语言提问:
### 环境变量配置 (.env)
> “上个月我们讨论过哪些发布问题?”
> “张三之前发过的项目地址在哪里?”
> “技术交流群今天有哪些结论和待办?”
可选配置项,可在 `.env` 文件中设置:
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
| 变量名 | 说明 | 示例 |
| ----------------------- | --------------------------- | --------------------------- |
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
## 💬 交流与反馈
#### 图片解密密钥说明
<p align="center">
<img src="./public/二维码.jpg" alt="TraceMemo 交流与售后群二维码" width="280" />
</p>
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
## 从你的任务开始
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
| ----------------------------------------- | ------------------------------------------------------- | ------------------------------------ |
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
这两个密钥可以通过以下方式获取:
## 最核心的三个能力
1. 从 WeFlow/Chatlog 设置中导出
2. 使用内存扫描工具从微信进程中自动提取(待实现)
### 浏览和搜索微信历史
## ⚠️ 免责声明
- 浏览联系人、群聊、折叠群聊和公众号消息。
- 查看文本、图片、视频、语音、文件、链接、引用、小程序等内容。
- 搜索会话或当前聊天中的关键词。
- 从 AI 结果跳回对应聊天位置。
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
详细说明:[聊天档案与普通搜索](./docs/user-guide/chat-archive.md)
## 🔗 参考
### AI 帮你找回聊过的内容
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
- [WechatExplorer](https://github.com/hicccc77/WechatExplorer)
- [chatlog](https://github.com/sjzar/chatlog)
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
<p align="center">
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
</p>
详细说明:[使用 AI 查找聊天信息](./docs/user-guide/ai-search.md)
### 直接在微信里问你的历史聊天
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。例如,你可以直接给机器人发送“最近 5 个会话”“张三最近和我聊了什么”,或者让它生成指定群聊的总结图片。TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
## 其他能力
### 本地知识库
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
### 生成群聊日报
<details>
<summary>查看群聊日报示例、内容和导出方式</summary>
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。报告可能包含热点、重要消息、资源、问答、待办、未解决事项、活跃统计和图片精选;具体内容取决于消息、媒体是否可读以及模型能力。
<p align="center">
<img src="./public/report-template-1.png" alt="群聊日报示例" />
</p>
详细说明:[生成群聊日报](./docs/user-guide/report.md)
</details>
### 转写微信语音
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
详细说明:[语音转文字](./docs/user-guide/voice.md)
### 防撤回
可选开启后,TraceMemo 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
详细说明:[防撤回](./docs/user-guide/recall-protection.md)
### 导出长期可用的聊天档案
支持 HTML、CSV、JSON 和 Markdown。HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
详细说明:[导出聊天](./docs/user-guide/export.md)
### 在外部 Agent 中查询微信历史
通过 Reader Skill 和本机 Local HTTP APICodex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。这和微信机器人是两条不同路径:微信机器人收到消息后在微信中回复;外部 Agent 则主动查询历史。
安装和技术说明请看[Agent 接入概览](./docs/agent/overview.md)与[Local HTTP API](./docs/agent/api.md)。
## 它如何工作
```mermaid
flowchart LR
A[本机微信数据] --> B[TraceMemo 读取与解析]
B --> C[聊天档案]
B --> D[本地知识库与搜索]
D --> E[筛选相关聊天来源]
E --> F[用户配置的 AI 模型]
F --> G[带来源的回答]
B --> H[整理日报输入]
H --> F
B --> I[聊天导出]
B --> J[Local HTTP API]
J --> K[外部 Agent]
L[微信机器人消息] --> M[Agent Hub]
M --> B
M --> F
```
- 微信数据库读取、聊天解析、知识库索引和离线语音识别在本机完成。
- 普通浏览、普通搜索和导出不要求配置 AI 服务。
- 使用“问问微信”、群聊日报或图片理解等 AI 功能时,完成任务所需的内容可能发送到你选择的模型服务;具体发送范围和确认方式以对应功能页面为准。
- “问问微信”会先在本机缩小范围,不会默认把整个微信数据库作为一次模型请求发送。
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
## 支持平台与安装包
| 平台 | 处理器架构 | Releases 安装包 |
| ------- | ------------------------------ | --------------- |
| Windows | x64 | `-setup.exe` |
| macOS | Apple SiliconM 系列、arm64 | `.dmg` |
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
## 快速开始
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
## 配置 AI
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
## 文档
- [文档首页](./docs/README.md)
- [第一次使用](./docs/user-guide/getting-started.md)
- [聊天档案与搜索](./docs/user-guide/chat-archive.md)
- [AI 查找聊天信息](./docs/user-guide/ai-search.md)
- [本地知识库](./docs/user-guide/knowledge.md)
- [群聊日报](./docs/user-guide/report.md)
- [语音转文字](./docs/user-guide/voice.md)
- [导出聊天](./docs/user-guide/export.md)
- [防撤回](./docs/user-guide/recall-protection.md)
- [数据、隐私与安全](./docs/user-guide/privacy.md)
- [Agent 接入](./docs/agent/overview.md)
- [微信机器人与 Agent Hub](./docs/agent/agent-hub.md)
- [Local HTTP API](./docs/agent/api.md)
- [开发与测试](./docs/development/overview.md)
## 本地开发
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
```bash
pnpm install
pnpm dev
```
常用检查:
```bash
pnpm typecheck
pnpm test:unit
pnpm test:component
pnpm test:integration
pnpm test:e2e:build
```
完整说明:[开发、测试与构建](./docs/development/overview.md)
## 支持与反馈
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。提交 Issue 时请提供操作系统、微信版本、TraceMemo 版本、复现步骤和已遮挡敏感信息的截图。
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
## 许可说明
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `LICENSE` 文件;贡献、复制或再分发前,请先向维护者确认 TraceMemo 本身的许可范围。
## 致谢
<details>
<summary>展开致谢与参考项目</summary>
TraceMemo 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
特别感谢:
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
- 提供了微信数据库解析相关思路。
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
- 参考了数据库密钥获取、图片解密等实现思路。
- **[chatlog](https://github.com/sjzar/chatlog)**
- 提供了聊天记录导出与数据处理方面的参考。
在此基础上,TraceMemo 进行了重新设计与实现,包括:
- AI 问问微信
- AI 群聊日报
- 本地 HTTP API
- Reader Skill
- Agent Hub
- 新手引导
- Electron + React 全新界面
- 本地优先 AI 工作流
感谢所有开源作者。
</details>
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+62
View File
@@ -0,0 +1,62 @@
# TraceMemo 文档
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
## 从这里开始
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。
## 你可以完成的任务
- [建立本地知识库](./user-guide/knowledge.md)
- [生成群聊日报和总结](./user-guide/report.md)
- [语音转文字](./user-guide/voice.md)
- [导出聊天档案](./user-guide/export.md)
- [防撤回](./user-guide/recall-protection.md)
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
- [数据、隐私与安全](./user-guide/privacy.md)
- [常见问题与排查](./user-guide/troubleshooting.md)
## 如果你想了解 AI 为什么这样回答
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。
## 微信机器人和外部 Agent
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
| 你想做什么 | 应该看哪里 |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| 在微信里给机器人发消息,让本机读取数据、生成总结并回复 | [Agent Hub](./agent/agent-hub.md) |
| 在 Codex、Claude Code、OpenClaw 等外部 Agent 中主动查询过去的微信数据 | [Reader Skill](./agent/reader-skill.md) + [Local HTTP API](./agent/api.md) |
### 在微信里提问
打开应用一级导航中的“Agent”,进入“Agent Hub”后扫码登录微信机器人。机器人收到文字消息后,可以查询最近会话、读取联系人聊天、生成群聊总结图片或总结群成员发言,并把结果回复给发消息的人。它需要本地微信数据库已经连接;依赖 AI 的任务还需要配置 AI 服务。
- [Agent Hub](./agent/agent-hub.md):连接机器人、查看运行状态和了解实时交互边界。
### 让外部 Agent 查询历史微信
连接 Reader Skill 后,你可以询问:
> “总结今天技术交流群讨论了什么。”
> “过去一周有没有人提到这个项目?”
- [Agent 接入概览](./agent/overview.md):先选择适合你的接入方式。
- [Reader Skill](./agent/reader-skill.md):安装并让外部 Agent 按需读取聊天。
- [Local HTTP API](./agent/api.md):完整端点和请求示例。
- [API 安全](./agent/api-security.md)Bearer Token、CORS、轮换和边界。
## 开发与平台
- [macOS 数据访问说明](./platform/macos.md)
- [开发、测试与构建](./development/overview.md)
- [本地启动排障](./development/local-startup-troubleshooting.md)
- [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md)
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
+69
View File
@@ -0,0 +1,69 @@
# 在微信里向 TraceMemo 提问(Agent Hub
Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导航中的“Agent”页面。你先扫码登录一个微信机器人账号,再用微信账号向机器人发送文字;本机 Agent Hub 会接收消息、读取已经连接的微信数据,必要时调用已配置的 AI,再把结果回复给发送者。
普通用户不需要安装 Reader Skill,也不需要配置 API Token。先连接微信数据库,再扫码登录机器人即可开始;需要总结或自然语言理解的任务还要配置 AI Provider。
它和 Reader Skill 是两条不同的路径:
- Reader Skill / Local HTTP API:外部 Agent 主动查询历史微信数据;
- Agent Hub / 微信机器人:机器人收到实时消息后处理并回复。
## 连接器和 Agent Hub 是什么关系
你不需要单独部署这些组件。扫码后,后台的微信连接器负责登录机器人、保持连接、接收微信消息和发送回复;Agent Hub 负责判断消息要做什么、查询 TraceMemo 本地数据、调用 AI 并组织结果。可以把它理解为:连接器负责“和微信通信”,Hub 负责“处理任务”。
## 你能做什么
连接 Agent Hub 后,可以在微信中询问:
- “最近 5 个会话”;
- “帮我看看最近跟某人聊了些什么。”
- “生成产品交流群今天的群聊总结图片。”
当前已实现的实时任务包括:
- 查看最近会话(数量限制为 1–20);
- 查询你和某位联系人的近期聊天;
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
- 生成今天、昨天或近 7 天的群聊总结图片;
- 总结指定群成员在群里的近期发言;
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
这些任务会在后台查询联系人、群聊和聊天记录,但当前机器人没有单独的“列出所有联系人”或“列出所有群聊”命令;需要完整浏览或按条件查询时,请使用档案页面或 Reader Skill / Local HTTP API。
## 连接步骤
1. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
2. 确认 Hub 显示“运行中”,数据库状态为“可查询”。
3. 点击“扫码登录微信机器人”。
4. 用微信扫描二维码;如果页面显示“已扫码,等待手机确认”,在手机上确认。
5. 状态变为“在线”后,用另一个微信账号向机器人发送测试问题。
可以重新扫码登录或断开连接。登录凭证失效时,需要重新扫码。
## 运行日志
Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支持筛选、复制和清空,并会隐藏 Token 和二维码数据,不记录微信密码。
## 需要满足的条件
- TraceMemo 的微信数据库已经连接,并且数据 API 可以查询;
- 依赖总结或自然语言理解的任务,需要在“设置 → AI 模型”配置可用的 AI 服务;
- TraceMemo 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
## 安全与边界
- Hub 使用本机通信,不把数据库直接暴露到公网;
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
## 无法连接时
先检查 Hub、连接器和数据库三项状态,再查看日志。二维码过期、连接器不存在、凭证失效和数据 API 未就绪分别需要重新扫码、修复安装、重新登录或先完成微信数据库连接。
+52
View File
@@ -0,0 +1,52 @@
# Local HTTP API 安全
## 当前安全边界
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
## Bearer Token
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
- `/api/v1/health` 是公开健康检查;
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`
- Token 由应用生成,使用 32 个随机字节编码;
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`
- 文件权限设置为 `0600`
- 在“API Center”中可以显示、复制和重新生成;
- 重新生成后旧 Token 立即失效。
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
```bash
export TRACEMEMO_API_TOKEN="<TOKEN>"
```
## CORS 与 Origin
带浏览器 `Origin` 的请求只允许精确的 HTTP loopback Origin
- `http://localhost` 及其端口;
- `http://127.0.0.1` 及其端口;
- `http://[::1]` 及其端口。
不带 `Origin` 的 curl、Node、本地脚本和 Agent 请求不受浏览器 CORS 规则限制,但仍必须携带 Token(health 除外)。
## 不要做的事
- 不要把 Token 放入 URL query、日志、截图、公开 Skill 或 Git;
- 不要把服务反向代理到公网;
- 不要把“health 能访问”误认为数据端点无需授权;
- 不要把 Bearer Token 当成跨用户权限系统;当前服务没有细粒度 Scope;
- 不要在共享机器上让不可信进程继承 Token 环境变量。
## Token 不可用时
如果系统安全存储不可用,API Token 会无法生成或读取,本地 API 会安全停用。先修复系统钥匙串/凭据服务,再回到 API Center 重试。不要手动编辑 `local-api-token.bin`
## 相关文档
- [Agent 接入概览](./overview.md)
- [Reader Skill](./reader-skill.md)
- [数据、隐私与安全](../user-guide/privacy.md)
- [v2.1.9 鉴权迁移说明](./release-notes-v2.1.9.md)
+96
View File
@@ -0,0 +1,96 @@
# TraceMemo Local HTTP API
本文面向需要自己写集成的开发者。普通用户请先阅读[Agent 接入概览](./overview.md)。
## 基本信息
- 默认地址:`http://127.0.0.1:6131`
- API 前缀:`/api/v1`
- 默认只监听 loopback;不要把它当作公网服务。
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`
- 请求体使用 JSON;响应为 JSON。
## 最小请求
```bash
# 健康检查
curl http://127.0.0.1:6131/api/v1/health
# 读取数据
export TRACEMEMO_API_TOKEN="<从 API Center 复制的 Token>"
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
```
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
## 端点
| 方法 | 路径 | 作用 | 参数/请求体 |
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter``type=user\|group` |
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time``startTime`/`endTime` |
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
### 这些端点与实时机器人有什么关系
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
- `/api/v1/agent/send` 是受 Bearer Token 保护的开发者/测试发送入口,用于通过已经连接的机器人发送文字或本地图片;它不是任意群发能力,也不是实时消息订阅接口;
- 当前 API 没有对外暴露实时入站 webhook。微信消息由应用内部的 Agent Hub 和微信连接器接收、处理和回复。
## 时间查询
`chatlog``time` 支持:
- `YYYY-MM-DD`:当天;
- `YYYY-MM-DD~YYYY-MM-DD`:日期闭区间;
- `YYYY-MM-DD/HH:mm`:从该分钟开始的 60 秒;
- 也可以使用 Unix 秒级 `startTime``endTime`
时间按运行 TraceMemo 的本机时区解析。用户说“今天”“昨天”时,先调用 `current_time`,再根据返回的 `localDate` 计算日期,避免使用 Agent 自己的时区。
## 常用工作流
### 查找并读取一个会话
```bash
BASE="http://127.0.0.1:6131/api/v1"
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
curl -H "$AUTH" "$BASE/resolve?q=技术交流群"
curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
```
当标识不确定时,先用 `resolve``contact`,再调用 `chatlog`。对重要问题,先宽范围定位,再针对关键时间点读取前后文,不要只凭一次粗查回答。
### 生成群聊总结图片
优先使用 `/api/v1/agent/group-report`,因为它会读取指定群聊并按 `today``yesterday``7days` 生成总结。`/api/v1/report` 是更底层的渲染接口,要求调用方已经准备好 `report``metadata` 结构;完整 TypeScript 类型以 `src/shared/group-report.ts` 为准。
## 响应与错误
- `200`:请求成功;
- `401`:缺少、错误或已失效的 Bearer Token
- `400`:参数或 JSON 请求体无效;
- `403`:浏览器 Origin 不在允许的 loopback 列表;
- `404`:端点、会话或群聊不存在;
- `503`:数据库或 Agent Hub 尚未就绪;
- `500`:服务端处理或报告渲染失败。
成功响应会返回端点对应的 JSON 对象,例如 `chatlog` 包含 `contact``query``count``messages``contact` 返回 `count``contacts`
## 与 MCP 的关系
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
+52
View File
@@ -0,0 +1,52 @@
# 在微信机器人或外部 Agent 中使用 TraceMemo
TraceMemo 提供两条不同路径。先按你实际想做的事选择,不需要先理解 Agent、Skill 或 API 等术语。
| 你想做什么 | 使用方式 | 需要什么 |
| ---------------------------------------------------- | ----------------------------- | --------------------------------------------------------- |
| 直接在微信里发文字,让本机查询聊天并回复 | 微信机器人(Agent Hub | 在应用“Agent”页面扫码登录机器人;部分任务需要 AI Provider |
| 在 Codex、Claude Code、OpenClaw 等工具里查询微信历史 | Reader Skill + Local HTTP API | 安装 Skill,并配置本机 API Token |
## 直接在微信里提问
打开应用一级导航中的“Agent”,进入“Agent Hub”,扫码登录一个微信机器人账号。之后用另一个微信账号向机器人发送文字,它会调用 TraceMemo 的本机数据,必要时使用已配置的 AI,再把结果回复给发送者。
可以先尝试:
- “最近 5 个会话”;
- “帮我看看最近跟张三聊了些什么”;
- “生成产品交流群今天的群聊总结图片”。
这条路径不要求安装 Reader Skill,也不要求用户配置 API Token。它主要处理文字请求,不支持群发、定时任务或与文字同等的任意媒体理解。
连接步骤、当前任务清单和安全边界见[Agent Hub](./agent-hub.md)。
## 在外部 Agent 中查询历史微信
Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以通过 TraceMemo Local HTTP API 按需读取联系人、群聊、最近会话、指定时间范围的聊天和群成员信息。
典型问题包括:
- “总结今天技术交流群讨论的内容。”
- “帮我找上个月讨论过的项目地址。”
- “过去一周有没有人提到退款?”
外部 Agent 不会直接打开微信数据库文件,但它能取得本机 API 返回的聊天内容。Agent 是否继续把结果发送给云端模型,取决于 Agent 自己的模型和工具配置。
## 外部 Agent 的安装步骤
1. 启动 TraceMemo 并完成微信数据库连接。
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
3. 选择目标 Agent,点击“复制安装指令”。
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `TRACEMEMO_API_TOKEN`
6. 先让 Agent 调用 health,再尝试查询最近会话。
详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。
## 不要混淆两条路径
- Agent Hub:微信机器人收到实时文字后处理并回复;
- Reader Skill/API:外部 Agent 主动查询历史数据;
- `127.0.0.1:6131` 是 Local HTTP API,不是 MCP Server
- Local HTTP API 当前没有对外提供实时入站消息订阅。
+62
View File
@@ -0,0 +1,62 @@
# Reader Skill:让外部 Agent 读取微信
## 先理解它能做什么
Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以按需调用 TraceMemo,读取联系人、群聊、最近会话、指定时间的聊天和群成员信息。
它使用的是 TraceMemo Local HTTP API,不是 MCP Server。
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
## 推荐安装流程
1. 启动 TraceMemo 并完成数据库连接。
2. 打开“API Center”,确认 API 服务和数据库状态正常。
3. 在 Reader Skill 区域选择目标 Agent,点击“复制安装指令”。
4. 把指令粘贴到对应 Agent 的 Skill/配置目录;应用会根据本机路径生成适合 Codex、Claude Code、OpenClaw 或通用 Agent 的说明。
5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置:
```bash
export TRACEMEMO_API_TOKEN="<YOUR_API_TOKEN>"
```
6. 先执行 health 检查,再读取数据端点。
TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
## Agent 的读取顺序
当用户使用“今天”“昨天”“本周”等相对时间时:
1. 调用 `/api/v1/current_time` 获取本机时区和日期;
2. 将相对时间换算为 `chatlog` 支持的 `time` 或时间戳;
3. 调用 `/api/v1/resolve`、`contact` 或 `chatroom` 确认会话;
4. 调用 `/api/v1/chatlog` 读取目标范围;
5. 对重要结论再读取关键消息前后文,不要只凭一次粗查。
## 最小请求
```bash
curl http://127.0.0.1:6131/api/v1/health
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
```
## 当前能力范围
Reader Skill 可以指导 Agent 使用:
- 联系人、群聊、最近会话和会话解析;
- 指定会话、日期或时间戳范围的聊天记录;
- 群成员快照;
- 结构化日报渲染和按群聊生成总结图片;
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
## 隐私边界
Reader Skill 本身不会把聊天数据自动上传到其他服务器;它只是让 Agent 调用本机 API。Agent 读取结果是否继续发送给云端模型,取决于 Agent 自己的模型和工具配置。请同时阅读[数据、隐私与安全](../user-guide/privacy.md)。
+12
View File
@@ -0,0 +1,12 @@
# TraceMemo 2.1.9Local HTTP API 鉴权迁移
2.1.9 为 Local HTTP API 增加 Bearer Token 鉴权。这是一次有意的兼容性变化:除健康检查外,数据接口不再接受裸请求。
- 历史版本中,`GET /api/v1/contact` 等数据请求可能直接返回内容;
- 2.1.9 中,相同请求必须携带 `Authorization: Bearer <TOKEN>`,否则返回 `401`
- `GET /api/v1/health` 保持公开;
- 升级后应用会生成并安全保存 Token,原有 API 启用状态、监听地址和端口设置保持不变;
- Token 可在 TraceMemo → API Center 中显示、复制和重新生成;
- Reader Skill、Codex、Claude Code、OpenClaw 和其他本地 Agent 需要在自己的环境中设置 `WECHATEXPLORER_API_TOKEN`
如果旧 Agent 无法访问,请先从 API Center 复制当前 Token,再确认每个非 health 请求都带有 Bearer header。完整规则见[API 安全](./api-security.md)。
+69
View File
@@ -0,0 +1,69 @@
# TraceMemo 2.2.0:正式品牌身份与安全升级迁移
TraceMemo(迹忆)原名 WechatExplorer。v2.2.0 不只更新用户可见名称,也正式启用新的应用身份、数据目录、Reader Skill 和默认 Agent 环境变量,同时为 v2.1.9 用户提供一次安全迁移路径。
## 新的产品身份
- 产品名与 Electron runtime name`TraceMemo`
- bundle/app identifier`com.tracememo.app`
- macOS userData`~/Library/Application Support/TraceMemo`
- macOS 日志:`~/Library/Logs/TraceMemo`
- Reader Skill`tracememo-reader`
- Agent API Token 环境变量:`TRACEMEMO_API_TOKEN`
- Agent Hub 凭据目录:`~/.tracememo/wechat-connector/accounts`
## v2.1.9 升级迁移
首次启动 TraceMemo 时,如果检测到包含有效用户资产的旧数据目录,应用会询问是否立即迁移:
- `WechatExplorer`
- v2.1.9 在区分大小写文件系统上可能使用的 `wechatexplorer`
两个旧目录都有效时,应用确定性优先选择 `WechatExplorer` 并写入诊断日志,不合并目录。选择“以后迁移”不会删除或修改旧数据,下次启动仍可继续处理。
迁移遵循以下安全边界:
- 只复制明确列出的用户资产,不复制整个 Application Support
- TraceMemo 已存在的文件或目录绝不覆盖;
- 每一项迁移可重复执行,已完成项会跳过;
- 迁移失败只清理本次创建的 staging,旧目录和旧文件始终保留;
- 不移动、不删除旧目录,不修改微信数据库或 Knowledge schema。
## 迁移的用户资产
- 设置、微信数据库连接路径和 AI Provider 元数据;
- Knowledge 本地索引;
- 报告历史、防撤回归档、图片理解结果和 Renderer Local Storage
- Local HTTP API Token
- AI Provider Key、微信数据库 Key 和图片解密 Key;
- Agent Hub credential 与同步状态。
Chromium Cache、Code Cache、GPUCache、临时文件、语音模型和其他可重建运行缓存不会为了品牌升级强制复制。
## Knowledge
Knowledge 以完整目录为单位复制。每个账号的 `knowledge.sqlite``knowledge.sqlite-wal``knowledge.sqlite-shm` 会一起进入同一个 staging;复制后先核对主库及 companion 文件,再对 staging 数据库执行 SQLite `integrity_check`。只有验证通过后才放入 TraceMemo 数据根。
迁移过程不会打开、修改或删除真实旧 Knowledge。失败时旧索引仍可用于重新迁移,不要求用户重新建立 2.47GB 级别的索引。
## Token 与加密 Key
`safeStorage` 密文不会原样复制到新数据目录。TraceMemo 会启动一个隔离的 legacy helpermacOS 使用旧 `WechatExplorer` identityhelper 只在内存中解密并校验旧 Token/Key,再通过专用进程管道交给主进程重新加密;macOS 主进程使用 TraceMemo identity. 明文不会写入磁盘、环境变量或日志。
Token 格式、随机熵、加密方式和 rotation 行为没有变化。如果旧 API Token 因系统安全存储限制无法迁移,应用不会静默生成替代 Token,本地 API 会安全停用并提示用户重试迁移或在 API Center 主动重新生成。AI Provider Key、数据库 Key 和图片 Key 失败时也会明确记录为部分迁移,旧密文保持不变。
## API、Agent 与 Skill 兼容
Local HTTP API 继续使用 `127.0.0.1:6131``/api/v1/*`Bearer Token 格式不变。
新安装和新文档默认使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可以在一个兼容版本内继续使用 `WECHATEXPLORER_API_TOKEN`。正式随应用分发的 Skill 已更名为 `tracememo-reader`,资源解析仍可读取旧 `wechatexplorer-reader` 目录作为 fallback。
Agent Hub 新凭据写入 `~/.tracememo`。如果迁移尚未完成且新目录没有凭据,connector 会只读回退到 `~/.wechatexplorer`;新版本不会清理或删除旧目录。
## 日志与 Documents
TraceMemo 新日志写入新的日志目录,“设置 → 关于 → 打开诊断日志目录”会打开当前 TraceMemo 日志。历史 WechatExplorer 日志保持原位置,不搬迁、不重命名、不删除。
`Documents/TraceMemo` 用于新导出和 Emoji 数据;历史 `Documents/WechatExplorer` 不删除,并继续提供兼容读取。
更多安全边界见[数据、隐私与安全](../user-guide/privacy.md)和[API 安全](./api-security.md)。
+41
View File
@@ -0,0 +1,41 @@
# 如何核对 AI 的回答来源
## 先记住一件事
AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内容来自哪个会话和时间,并跳回原始消息检查上下文。
这让 TraceMemo 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
## 三类来源信息
在产品界面和检索详情中,你可能看到这些名称:
- **Evidence**AI 回答所依据的原始聊天片段。
- **Citation**:回答中某个结论对应的来源标记。
- **Search Trace**:本次查找经历了哪些阶段、每一步用了多久、覆盖是否完整。
普通用户不需要记住英文名。判断一个回答是否可信时,按“来源 → 原消息 → 上下文”检查即可。
## 推荐的核对顺序
1. 先看回答是否明确区分事实、推断和不确定信息;
2. 打开来源,检查发送者、会话和时间;
3. 跳回档案,查看消息前后文,确认是否存在引用、转发或后续修正;
4. 检查提示中是否有未转写语音、缺失媒体或只覆盖部分范围;
5. 对重要决定、金额、日期和责任人,不要只依据 AI 摘要。
## 为什么来源可能不完整
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
- Knowledge 正在同步时,新的分析会被暂停;
- 语音没有转写时,AI 可能只能看到消息类型;
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
- 你只选择了一个群,答案不会自动代表所有聊天。
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
## 这不是事实保证
Evidence 和 Citation 能告诉你“模型看到了什么”,不能保证模型没有误读。最终判断仍应回到原始消息,尤其是涉及隐私、法律、财务、医疗或工作决策时。
+51
View File
@@ -0,0 +1,51 @@
# TraceMemo 如何把聊天变成可用的信息
你可以把一次任务想成下面这条路径:
```mermaid
flowchart LR
A[本机微信数据] --> B[读取与解析]
B --> C[聊天档案与普通搜索]
B --> D[本地知识索引]
D --> E[筛选相关消息]
E --> F[用户配置的 AI Provider]
F --> G[回答与可核对来源]
B --> H[聊天导出]
B --> I[整理日报输入]
I --> F
F --> J[本地保存 HTML 与 PNG]
B --> K[Local HTTP API]
K --> L[外部 Agent]
M[微信机器人消息] --> N[Agent Hub]
N --> B
N --> F
```
## 哪些步骤在本机
- 微信数据库读取与解析;
- 聊天档案浏览和普通搜索;
- Knowledge 索引与增量同步;
- 离线语音转写;
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
## 哪些步骤可能调用外部服务
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
如果 Provider 是 Ollama 等本机服务,请把它视为本机的另一个进程;如果是云服务,数据处理和留存规则由该服务商决定。
## 产品名词和用户任务的对应关系
| 用户想做什么 | 产品中可能看到的名称 |
| ------------------------ | ---------------------------- |
| 让 AI 找相关聊天 | AI Search、Retrieval |
| 让答案能回到原消息 | Evidence、Citation |
| 查看 AI 查找过程 | Search Trace |
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
| 让微信机器人调用本机能力 | Agent Hub |
先按任务使用,再在需要排查或开发集成时阅读术语。
@@ -0,0 +1,62 @@
# 本地启动排障
本文面向运行源码开发环境的贡献者。常规启动顺序和测试入口请先阅读[开发、测试与构建](./overview.md)。
## 启动成功的判断标准
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`
- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`
`6131` 是应用提供给本机集成使用的 API 端口,不是 Vite 的页面端口。
## Go 命令找不到
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
```bash
go version
```
命令不可用表示当前终端的 `PATH` 没有找到 Go。Windows 默认安装位置是 `C:\Program Files\Go\bin`。确认 Go 已安装并把该目录加入系统 `PATH` 后,关闭并重新打开终端或 IDE,再重新执行 `go version``pnpm dev`
如果 Go 刚完成安装,已经打开的终端不会自动继承新的环境变量;重开终端是必要步骤。不要绕过连接器构建直接启动 `electron-vite dev`,否则 Agent Hub 的微信连接器不会生成。
## Electron 二进制缺失或下载失败
`electron-vite dev``Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
项目的 [`.npmrc`](../../.npmrc) 已设置:
```ini
electron_mirror=https://npmmirror.com/mirrors/electron/
```
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
```bash
pnpm install --frozen-lockfile
```
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
```powershell
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
pnpm install --frozen-lockfile
```
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
## 页面地址无法通过 IPv4 访问
Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时直接访问 `http://127.0.0.1:5173/` 可能失败,但 `http://localhost:5173/` 仍然正常,Electron 也会使用后者加载页面。
排查时优先访问 `http://localhost:5173/`;需要显式验证 IPv6 时,使用 `http://[::1]:5173/`。不要因为 IPv4 回环地址不可用就判断 Electron 或 Vite 启动失败。
## 仍无法启动时
保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile``pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
+58
View File
@@ -0,0 +1,58 @@
# 开发、测试与构建
本文面向希望参与 TraceMemo 开发、验证文档或维护集成的贡献者。普通用户请从[第一次使用](../user-guide/getting-started.md)开始。
## 技术基线
- Electron + React + TypeScript
- pnpm 7+
- Go(构建微信连接器);
- 平台对应的 Electron/native 构建环境。
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
## 本地开发
```bash
pnpm install
pnpm dev
```
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
常用检查:
```bash
pnpm typecheck
pnpm test:unit
pnpm test:component
pnpm test:integration
pnpm test:e2e:build
```
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。
## 代码变更对应文档
| 代码区域 | 需要同步检查的文档 |
| --------------------------------------------------------- | ---------------------------------------------------------- |
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md``concepts/answer-sources.md` |
| `src/shared/knowledge.ts``src/main/knowledge/` | `user-guide/knowledge.md``concepts/how-it-works.md` |
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
| `src/main/services/recall-archive-service.ts`、防撤回设置 | `user-guide/recall-protection.md``user-guide/privacy.md` |
| `src/shared/local-api-test.ts``src/main/http-server.ts` | `agent/api.md``api-security.md`、打包 Skill |
| Agent Hub service/UI | `agent/agent-hub.md``user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md``docs/README.md` |
## 文档检查
提交文档变更前至少执行:
```bash
git diff --check
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!DOCUMENTATION_AUDIT.md' --glob '!development/overview.md'
```
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
+5
View File
@@ -0,0 +1,5 @@
# macOS 数据访问说明(兼容入口)
完整内容已移到[macOS 数据访问与系统权限](./platform/macos.md)。
保留此文件是为了兼容应用内已经发布的帮助链接。请不要把“关闭 SIP”当作默认安装步骤;只有当当前连接页面明确要求时才处理,并在完成后恢复系统安全设置。
+27
View File
@@ -0,0 +1,27 @@
# macOS 数据访问与系统权限
## 你什么时候会看到这些提示
TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
## 推荐步骤
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
2. 确认微信数据目录指向当前账号。
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
4. 返回应用重新检测账号、数据库和图片资源状态。
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
## SIP 风险
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 TraceMemo 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
## 应用无法打开
如果 macOS 阻止未验证的应用,使用系统“隐私与安全性”中的“仍要打开”选项。不要为了绕过提示下载来历不明的补丁或替换应用文件。
## Intel 与 Apple Silicon
从 Releases 选择与 Mac 处理器匹配的构建。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
+65
View File
@@ -0,0 +1,65 @@
---
name: tracememo-reader
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
---
# TraceMemo Reader
你是一个通过本机 TraceMemo 读取微信历史的 Agent。先确认用户已经在 TraceMemo 中完成数据库连接,再按需调用 API;不要假设数据库已就绪,也不要声称读取了没有调用过的消息。
## 连接信息
- Base URL 默认是 `http://127.0.0.1:6131/api/v1`
- `GET /health` 不需要 Token。
- 其他端点必须带 `Authorization: Bearer $TRACEMEMO_API_TOKEN`
- 新配置优先读取 `TRACEMEMO_API_TOKEN`;为兼容已安装的旧 Reader,可在新变量缺失时回退到 `WECHATEXPLORER_API_TOKEN`
- Token 由用户在 TraceMemo → API Center 显示/复制,并放在 Agent 自己的本地环境中。
- 不要把 Token 放到 URL、回答、日志、Skill 文件或仓库。
- 6131 是普通 Local HTTP API,不是 MCP Server;不要生成 `mcpServers` 配置。
## 每次任务前
1. 调用 `/health`,确认服务和数据库状态。
2. 用户说“今天”“昨天”“本周”等相对时间时,先调用 `/current_time`,按返回的本机时区换算日期。
3.`/resolve``/contact``/chatroom` 确认会话标识。
4.`/chatlog` 读取最小必要的时间范围。
5. 对重要结论读取关键消息前后文;不要只凭一次宽范围粗查回答。
## 端点速查
| 方法 | 路径 | 用途 |
| ---- | --------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter``type` |
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
| POST | `/agent/send` | 已连接机器人发送测试 |
## 时间与上下文规则
`/chatlog``time` 支持 `YYYY-MM-DD`、日期闭区间和分钟范围;也可以使用 Unix 秒级 `startTime`/`endTime`。时间按 TraceMemo 所在机器的本机时区解释。
当用户问“某个话题是谁说的、后来结论是什么”时,先定位会话和时间,再读取关键消息前后文。回答时区分:
- 原消息明确写出的内容;
- 根据多条消息整理出的总结;
- 没有来源支持的推断。
## 隐私和安全
只读取用户请求所需的会话和时间范围。不要把完整聊天数据库、密钥或 Token 暴露给用户。Reader API 本身不自动把聊天转发到外部服务器,但当前 Agent 可能会把工具结果交给其配置的模型;如有疑问,提醒用户检查 Agent 的数据策略。
## 常见错误
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
- `404`:先用 `/resolve` 确认会话标识。
- `503`:用户还没有完成数据库连接或对应服务未就绪。
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
+61
View File
@@ -0,0 +1,61 @@
# 用 AI 查找你以前聊过的信息
## AI Search 是什么
你可以把它理解成“会帮你翻聊天记录的 AI”。
普通搜索需要你猜关键词;AI Search 更适合这些问题:
- “我们上个月为什么决定延期?”
- “谁提过这个项目,后来结论是什么?”
- “过去一周有哪些待跟进事项?”
它会先在本机查找相关聊天,再把受控范围内的内容交给你选择的 AI Provider 生成回答。它不是凭空记忆,也不是把整库聊天一次性上传。
## 第一次使用
1. 进入“设置 → AI 模型”,添加一个 Provider,填写服务地址、模型和认证信息,然后测试连接。
2. 打开“问问微信”。
3. 选择所有聊天、群聊、单聊或当前会话,并选择今天、近 7 天、近 30 天或不限时间;范围越明确,答案越容易核对。
4. 输入问题并开始分析。
如果知识库尚未建立,页面会提示你建立或同步;你也可以先直接使用当前可用的搜索路径。
## 怎么提问更容易得到好结果
把“谁、什么时候、在哪个群、想找什么结果”写出来。例如:
> “在产品交流群里,查找 2026 年 7 月讨论发布延期的消息,列出结论和待办。”
尽量避免只写“总结一下”。如果你只记得模糊含义,也可以先提问,再根据来源缩小范围继续追问。
## AI 回答后先看什么
不要只看结论。回答区域通常还会展示:
- 参考了哪些聊天内容;
- 来源来自哪个会话、发送者和时间;
- 哪一段回答对应哪条来源;
- 本次查找经过了哪些阶段、耗时和覆盖情况;
- 是否存在未转写语音、媒体不可用或结果不完整的提示。
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
## 什么时候不要直接相信答案
- 来源很少,或时间范围与问题不一致;
- 回答提到了来源中没有的细节;
- 关键内容来自未转写语音、无法读取的图片或转发消息;
- 页面提示只覆盖了部分聊天。
这些情况下,打开原消息,扩大或缩小范围,再重新提问。必要时把问题改成“只列出原文明确说过的内容”。
## 取消、失败和降级
分析过程中可以取消当前任务。检索或模型请求失败时,页面可能保留已找到的来源或切换到备用路径;这不代表一定得到了完整答案。请查看提示、检索详情和[排障文档](./troubleshooting.md#ai-没有结果或回答失败)。
## 数据会发到哪里
本地解析、索引和候选消息查找在本机完成。只有完成 AI 任务所需的用户问题、受控检索上下文和最终用于总结的来源内容,才可能发送到你配置的 Provider;具体边界见[数据、隐私与安全](./privacy.md)。
使用远程 Provider 时,当前界面会在本次请求发出前显示接收方和发送范围,等待你确认。当前实现最多发送 8 条最终来源,不会发送完整微信数据库、数据库密钥、绝对文件路径或内部会话/消息引用 ID;这次确认不会自动授权之后的其他请求。
+57
View File
@@ -0,0 +1,57 @@
# 查看和搜索聊天
“档案”是你直接阅读微信历史的地方。适合查原文、回看上下文、确认 AI 来源,也适合在你已经知道关键词时快速定位。
## 选择要看的会话
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
## 普通关键词搜索什么时候最好用
当你记得以下任意信息时,优先使用档案搜索:
- 一段原话或关键词;
- 人名、群名、项目名;
- 链接、文件名或订单号;
- 大致知道在哪个联系人或群里。
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
## 消息和媒体
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
不要把“消息类型已读取”理解成“所有媒体都一定能解码”。遇到图片或视频空白时,请先检查[媒体与导出排查](./troubleshooting.md#媒体显示或导出异常)。
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
## 可选保留撤回消息
“设置 → 防撤回”提供一个默认关闭的可选功能。开启后,应用会尽量保留之后捕获到的撤回消息,并在气泡旁标记“消息已撤回”。它不能找回开启前已经消失或应用未捕获到的内容,也可能增加加载开销。
该功能与普通只读浏览的数据边界不同。开启前请阅读[防撤回](./recall-protection.md)。
## 保护自己不被误导
档案中的原始消息是核对 AI 结果的最终依据。看到 AI 的总结、日报或来源时,建议:
1. 打开来源对应的会话;
2. 查看消息前后几条上下文;
3. 注意消息时间、发送者和是否存在转发/引用;
4. 对未转写的语音、无法读取的图片保持不确定判断。
## 常见问题
### 会话列表为空
确认数据库连接成功、连接的是正确微信账号,并重新加载数据。若仍为空,查看[连接微信失败](./troubleshooting.md#连接微信失败)。
### 搜索不到明明存在的消息
先缩小到正确会话,再尝试更短的关键词或原文片段。对于“以前讨论过什么”这类语义问题,改用[AI Search](./ai-search.md)。
### 想跨多个会话查找
使用“问问微信”,并在问题中写清时间范围、人物或群聊范围。需要更稳定的跨会话查找时,先建立[本地知识库](./knowledge.md)。
+39
View File
@@ -0,0 +1,39 @@
# 导出聊天档案
导出适合把微信里的重要讨论保存成可阅读、可分享或可继续处理的文件。
## 支持的格式
| 格式 | 适合什么任务 | 当前边界 |
| -------- | ------------------------ | ---------------------------------------------------------- |
| HTML | 完整阅读和长期归档 | 可包含媒体、头像和可选语音转写;支持多会话、增量合并和 ZIP |
| Markdown | 笔记、版本管理和再次编辑 | 主要保留文本内容,不复制 HTML 资源文件 |
| CSV | 表格分析 | 主要保留文本内容,不复制 HTML 资源文件 |
| JSON | 程序处理和数据归档 | 主要保留文本内容,不复制 HTML 资源文件 |
ZIP 是 HTML 资源包的压缩选项,不是第五种内容格式。
## 导出步骤
可以打开一级导航“导出”,也可以在“档案”的聊天顶部点击“导出”并选择时间范围。
1. 选择一个或多个联系人/群聊。
2. 选择时间范围和消息类型。
3. 选择格式;只有 HTML 可以配置媒体资源、语音转写和 ZIP。
4. 按需要设置头像、原图/缩略图和缺失资源处理。
5. 设置文件名并开始导出。
6. 在导出任务中心查看读取、解析、媒体处理、转写、写入和压缩进度;完成后打开文件位置。
## 多会话和增量导出
HTML 支持把最多五个会话合并到一个档案中;选择多个会话后,其他格式会不可用。再次使用相同名称导出 HTML 时,可以把新消息增量合并到已有档案;这不会删除之前已导出的消息。
## 媒体怎么处理
原图、缩略图、缺失资源和头像都可能影响导出大小与可读性。想要小文件时关闭媒体或选择缩略图;想要长期保存时,确认原始媒体目录仍可访问,并考虑 ZIP 归档。
HTML 导出可以选择在任务中执行本地语音转写,并把成功结果显示在语音气泡下方。语音模型不可用或识别失败时,导出不会把失败内容当成已转写文本。
## 导出和原始数据的关系
导出是复制/整理结果,不会修改微信原始数据库。删除导出文件也不会影响应用内聊天记录或本地知识库。
+151
View File
@@ -0,0 +1,151 @@
# 第一次使用 TraceMemo
如果你刚下载 TraceMemo,只需要完成一条主线:
> 安装应用 → 连接微信数据 → 确认聊天已加载 → 搜索或提问。
这篇文档不要求你先学习内部术语;先把第一个问题问出来,之后再按需要深入了解产品名称和进阶功能。
## 1. 开始前准备
| 系统 | 已测试的微信客户端 | 需要注意 |
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
- 你需要有权访问要读取的微信账号和聊天数据。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
## 2. 安装并启动
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
### Windows
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。
2. 双击安装包,按向导完成安装。
3. 启动 TraceMemo。
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
### macOS
1. 下载 Apple SiliconM 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
4. 如果系统提示应用已损坏,可在终端执行:
```bash
xattr -cr "/Applications/TraceMemo.app"
```
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
## 3. 让应用读取微信数据
首次启动会自动进入“第一次使用”页面。页面会根据当前系统显示连接方式和注意事项:
<p align="center">
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
</p>
通常按下面三步操作即可:
1. **确认微信数据目录**:自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径并在页面中修改。
2. **让微信停在登录页面**:如果微信已经登录,先退出当前微信账号,不只是关闭微信窗口。
3. **点击“开始连接”并按提示获取密钥**:软件准备好连接组件后会提示你登录微信;回到微信完成登录,再等待数据库、账号和联系人检查完成。
只有已经通过其他方式取得当前账号数据库密钥的高级用户,才需要选择“手动连接”。Windows 不需要关闭 SIP;macOS 是否需要额外授权或处理 SIP,以当前连接页面提示为准。
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
## 4. 确认第一次连接成功
连接成功后会进入“档案”页面。你可以用下面三个信号确认已经准备好:
- 左侧出现联系人或群聊列表;
- 选中一个会话后,右侧能看到历史消息;
- 搜索框可以在当前会话中定位文字。
如果联系人列表为空,先检查是否连到了正确账号和数据目录,再重新加载会话。
文字消息正常但图片打不开时,不代表数据库连接失败。打开“设置 → 图片解密”查看状态并尝试自动获取;图片原文件缺失、权限不足或密钥不匹配时,部分图片仍可能无法显示。
## 5. 完成你的第一个任务
### 只是想找一句话
进入“档案”,选择联系人或群聊,在会话内搜索关键词。适合你记得原话、姓名、链接或大致关键词的情况。
### 想找一个模糊的结论
先在“设置 → AI 模型”添加并测试一个 Provider,再进入“问问微信”描述问题,例如:
- “上个月技术群讨论过哪些发布问题?”
- “张三之前发过的项目地址在哪里?”
- “过去一周有没有人提到退款?”
这就是 AI Search:它会先帮你从本机聊天中找出相关内容,再让你配置的模型组织答案。你不需要知道关键词在哪,但问题越具体,结果越容易核对。
### 想让 AI 的答案可核对
回答生成后,打开来源或检索详情,查看它参考的聊天内容、会话、时间和原始消息。你可以从来源直接跳回“档案”检查上下文。
产品把这些来源信息分别称为 Evidence、Citation 和 Search Trace;普通使用时只需要记住“答案可以回到原消息核对”即可。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
## 6. 接下来可以做什么
- [查看和搜索聊天](./chat-archive.md)
- [使用 AI 查找聊天信息](./ai-search.md)
- [建立本地知识库,让后续查找更稳定](./knowledge.md)
- [生成群聊日报或总结](./report.md)
- [转写微信语音](./voice.md)
- [导出聊天档案](./export.md)
- [可选开启防撤回](./recall-protection.md)
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
- [让外部 Agent 查询微信历史](../agent/overview.md)
## 7. 想直接在微信里提问
如果你希望直接在微信里向 TraceMemo 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub
1. 先完成上面的微信数据库连接,并确认“档案”里能看到聊天。
2. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
3. 确认 Agent Hub 显示“运行中”,数据 API/数据库状态可以查询。
4. 点击“扫码登录微信机器人”,用微信扫描二维码,并在手机上确认登录。
5. 状态变为“在线”后,向这个机器人发送文字消息。
可以先试试这些真实支持的请求:
- “最近 5 个会话”;
- “帮我看看最近跟张三聊了些什么”;
- “生成产品交流群今天的群聊总结图片”。
机器人会把处理结果回复给发消息的人。联系人聊天总结、群聊总结和需要理解自然语言的请求依赖“设置 → AI 模型”中已经配置好的 AI 服务。当前实时入口主要处理文字消息;它不是支持任意图片、语音、文件理解、群发或定时任务的通用机器人。机器人账号扫码登录与读取你微信数据库是两条独立流程,都需要分别确认账号和权限。
Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Skill 或配置 API Token。Reader Skill 和 API 只用于让外部 Agent 主动查询历史微信。
## 8. 需要配置 AI 吗?
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
## 9. 数据和隐私的最低须知
- 微信数据库、聊天解析和本地索引默认留在本机。
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
完整边界见[数据、隐私与安全](./privacy.md)。
## 10. 如果你卡住了
按现象进入[常见问题与排查](./troubleshooting.md):连接失败、聊天为空、AI 没有结果、语音模型不可用、导出失败和 Agent 无法访问分别有不同处理方式。
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

+38
View File
@@ -0,0 +1,38 @@
# 把聊天变成更容易再次找到的本地资料
## 你为什么需要 Knowledge
如果你经常查同一批工作群、项目讨论或长期联系人,只靠每次临时翻聊天会越来越慢。Knowledge 会在本机建立一份可重复查找的索引,让“以前聊过什么”这类问题更容易跨会话、跨时间找到相关内容。
它不是另一个聊天窗口,也不会替你修改微信原始数据库;它是 TraceMemo 为当前账号维护的本地加速资料。
## 建立和同步
Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信”后,在“本地知识库”区域点击:
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。
## 账号隔离
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
## 什么时候值得建立
- 你要跨多个群查过去几个月的内容;
- 你反复查同一个项目、客户或主题;
- 你希望 AI 先从更稳定的本地资料中找来源;
- 你想减少每次搜索都重新读取大量原始记录的等待。
只偶尔查一条原话时,直接使用档案搜索通常更快。
## 清理和重建
在“设置 → 缓存与清理”中可以清理本地知识库索引、检索记录和导出任务缓存。清理索引不会删除微信原始聊天记录或数据库密钥;之后可以回到“问问微信”重新建立。
## 产品术语(可选)
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
+61
View File
@@ -0,0 +1,61 @@
# 数据、隐私与安全
TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有功能都完全离线。是否有数据离开电脑,取决于你是否启用了对应的 AI、Agent 或机器人能力。
## 默认留在本机的内容
以下处理由应用在本机完成:
- 读取和解析微信数据库;
- 聊天档案浏览和普通关键词搜索;
- 本地 Knowledge 索引及其账号隔离;
- 离线语音转写;
- 导出文件生成和本地日报历史。
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
## 什么时候会请求外部服务
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
- 当前用户问题;
- 受控检索所需的有限上下文;
- 最终用于总结的 Evidence。
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 TraceMemo 控制,请查看你所选服务商的政策。
Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的日志和配置仍由你负责。
## 语音和媒体
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
## Local HTTP API
- 默认监听地址为 `127.0.0.1:6131`,不是公网服务;
- `/api/v1/health` 为公开健康检查;
- 其他端点需要 `Authorization: Bearer <TOKEN>`
- 浏览器 CORS 只允许 HTTP 的 `localhost``127.0.0.1``[::1]` Origin
- 不带 Origin 的本地 CLI/Agent 请求可以使用 Token 访问;
- API 不适合直接转发到公网或绑定到不受信任的网络接口。
Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `local-api-token.bin`,文件权限为仅当前用户可读写。你可以在“API Center”中显示、复制或重新生成 Token;重新生成会立即使旧 Token 失效。具体配置见[API 安全](../agent/api-security.md)。
## Agent 访问时发生什么
外部 Agent 通过 Reader Skill 调用本机 API,按需读取联系人、会话或时间范围内的聊天;它不会因此获得数据库文件路径或任意文件系统权限。Agent 是否把读取结果再次发送给模型,取决于 Agent 本身及其配置。
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 TraceMemo,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
机器人收到的文字会先进入本机 Agent Hub;如果任务需要总结或自然语言理解,受控上下文可能发送给你配置的 AI Provider。机器人账号扫码登录、个人微信数据库连接和外部 Agent/API Token 是不同的边界,使用前请分别确认账号与权限。
## 你可以主动做的事
- 不要把 API Token 放进 Git、截图、URL 或公开 Skill 文件;
- 只连接你有权访问的微信数据;
- 对需要外发的 AI 功能逐项确认 Provider
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
- 在共享电脑上退出应用并保护系统账户。
- 在开启防撤回前确认你接受其数据库写入、性能和清理边界,并先用微信官方方式备份重要数据。
+37
View File
@@ -0,0 +1,37 @@
# 防撤回
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
## 如何开启
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
2. 打开“设置 → 防撤回”。
3. 阅读性能和数据提示后,开启“防撤回”。
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
## 当前能做什么
- 监听应用能够识别到的后续撤回变化;
- 在本地保留必要的消息和撤回关系;
- 将已识别的原消息与撤回状态一起显示在档案中;
- 按微信账号隔离 TraceMemo 保存的恢复记录。
## 当前限制
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
## 数据写入与关闭边界
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
完整的数据边界见[数据、隐私与安全](./privacy.md)。
+42
View File
@@ -0,0 +1,42 @@
# 生成群聊日报和总结
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 TraceMemo 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
## 报告适合做什么
典型场景包括:
- 整理今天工作群的讨论重点;
- 回顾昨天错过的决定和资源;
- 汇总近 7 天的项目进展、待办和未解决问题;
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
## 生成步骤
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
2. 选择时间范围:今天、昨天或近 7 天。
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
4. 选择报告模板/内容模式并开始生成。
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
报告可能包含主题、重要消息、问答、资源、待办、未解决事项、关键词、活跃统计,以及可用媒体的精选内容。具体展示内容会随消息类型、资源可用性和模型能力变化。
## 如何检查报告
报告中的重点结论会关联来源消息。对于重要决定、金额、时间和责任人,打开对应原消息核对,不要把 AI 生成的摘要当成新的事实来源。
图片无法读取时,报告可能只保留消息类型和上下文;模型未通过图片理解验证时,图片精选会被跳过。语音在日报中可参与数量和活跃度统计,但不要把统计当成语音内容已经被完整转写。
## 保存、查看和删除
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
## 让报告更可靠
- 先选正确的群和时间范围;
- 不确定时先只选择文字消息;
- 群太活跃时分成“今天”和“近 7 天”两次生成;
- 看到待办和结论后回到原消息核对上下文;
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
+94
View File
@@ -0,0 +1,94 @@
# 常见问题与排查
先按现象定位,不要为了“重置”而直接删除微信数据库或整个应用目录。
## 安装后软件无法打开
### Windows
1. 确认下载的是 GitHub Releases 中的 Windows x64 `-setup.exe`,并已完成安装。
2. 安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
3. 安装完成后重新启动 TraceMemo;如果仍无响应,再重新运行安装包进行覆盖安装。
### macOS
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`
完整安装步骤见[第一次使用 TraceMemo](./getting-started.md#2-安装并启动)。
## 连接微信失败
依次检查:
1. 数据目录是否指向当前登录账号,而不是旧备份或迁移前目录;
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
3. 微信是否处于页面要求的登录/退出状态;
4. macOS 是否完成页面要求的授权;
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
## 连接成功但没有联系人或消息
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
## AI 没有结果或回答失败
- 先在“设置 → AI 模型”测试 Provider
- 检查问题的时间范围和会话范围是否过窄;
- 确认 Knowledge 没有正在同步;
- 打开检索详情,查看是本地查找为空、Provider 失败还是来源被过滤;
- 把问题改成要求“只根据来源原文回答”。
AI Search 失败时可能仍保留部分来源;不要把部分结果当成完整覆盖。
## AI 答案看起来不对
打开来源和原始消息,检查发送者、时间和上下文。若来源不支持结论,扩大或缩小范围后重问。涉及未转写语音、缺失图片、转发和引用时,优先以原消息为准。
## Knowledge 一直在同步
首次建立或增量同步会在后台运行。查看“已索引消息、知识片段、磁盘占用”和同步详情;同步期间暂不能开始新的 AI 分析。若出现错误,旧索引可能仍可用,重启应用或在“缓存与清理”清理后重新建立。
## 语音转写失败
检查本地模型是否已准备、磁盘空间是否足够、单条语音是否仍有原始资源。批量任务可能部分成功;先处理失败项,不必重复转写已缓存内容。
## 媒体显示或导出异常
原图/缩略图目录缺失、权限不足或微信资源已被清理都会导致图片、视频或语音不可用。导出时可以切换缩略图、关闭媒体或保留缺失项,先确认文本档案是否正常。
文字正常但图片打不开时,进入“设置 → 图片解密”查看状态并尝试自动获取。密钥正确也不能恢复已经被微信清理的原图文件。
## 日报生成失败
日报只支持群聊。确认已选择群聊、时间范围内确实有消息、Provider 可用,并尝试先只选择文字消息。图片理解失败不会自动变成图片内容;报告可能跳过图片精选但仍生成文字日报。
## Agent 无法读取
确认:
1. TraceMemo 正在运行且 API Center 显示本地服务在线;
2. Agent 使用的是当前 Reader Skill,而不是旧的 MCP 配置;
3. 请求地址为 `http://127.0.0.1:6131`
4. 非 health 请求带有最新 `Authorization: Bearer <TOKEN>`
5. Token 重新生成后,Agent 配置已同步更新。
详细步骤见[Agent 接入概览](../agent/overview.md)和[API 安全](../agent/api-security.md)。
## 微信机器人无法连接或不回复
Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
1. “Agent”页面中的 Agent Hub、微信连接器和数据库状态是否正常;
2. 二维码是否过期,手机是否已经确认登录;
3. 是否由另一个微信账号向已登录的机器人账号发送文字;
4. 请求是否属于当前支持的最近会话、联系人聊天、近 7 天联系人总结、群聊总结或群成员发言总结;
5. 需要总结或自然语言理解时,AI Provider 是否可用。
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
## 防撤回没有保留消息
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
+37
View File
@@ -0,0 +1,37 @@
# 语音转文字
TraceMemo 可以把微信语音转换成可搜索的文字,适合你不想逐条播放、希望把语音内容带入后续查找或导出的场景。
## 使用前准备
1. 打开“设置 → 语音识别”。
2. 按页面提示准备或下载本地语音模型。
3. 等待模型状态显示可用。
语音识别使用本地 SenseVoice/sherpa-onnx 运行时。首次准备模型可能需要下载文件和占用额外磁盘空间;模型文件可以从设置中删除,之后需要重新准备。
## 转写单条语音
在聊天档案中找到语音消息,点击转写入口。完成后,转写文本会与该消息关联,并可用于后续查看或检索。失败时查看消息提示和模型状态。
## 批量转写
在语音设置中选择联系人或群聊,再选择范围:
- 最近 30 天;
- 当前年份;
- 选择的历史范围。
开始前页面会显示语音条数、已缓存数量、待处理数量和预计耗时。批量任务支持进度、取消、缓存复用,并可能以“部分失败”结束;部分失败时可以根据列表重新处理未成功内容。
## 和 AI、知识库、导出的关系
- 本地转写结果可以参与本地知识库检索;
- 导出时可选择是否包含已有语音转写;
- AI Search 可能提示某些语音尚未转写,这意味着答案覆盖不完整;
- 群聊日报默认会统计语音数量和时长,但不等于已经理解了每条语音的具体内容。
## 隐私提示
离线转写本身在本机完成。若你主动把转写结果用于 AI Search、日报或其他 AI 功能,受控文本可能按对应功能的规则发送给你配置的 Provider;详见[数据、隐私与安全](./privacy.md)。
+28 -9
View File
@@ -1,5 +1,5 @@
appId: com.wechatexplorer.app
productName: WechatExplorer
appId: com.tracememo.app
productName: TraceMemo
afterPack: scripts/after-pack.cjs
directories:
buildResources: build
@@ -15,33 +15,50 @@ extraMetadata:
main: out/main/index.js
asarUnpack:
- resources/**
- node_modules/ffmpeg-static/**
- node_modules/silk-wasm/**
- node_modules/sherpa-onnx-node/**
- node_modules/sherpa-onnx-*/**
extraResources:
# Includes the optional WeChat connector binary for the target platform.
- from: resources
to: resources
filter:
- '**/*'
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
- '**/*'
win:
executableName: wechatexplorer
icon: icon.ico
# WCDB's Windows runtime checks the host executable name. The dev runtime is
# electron.exe, so keep the packaged executable compatible while using
# TraceMemo as the product/shortcut name.
executableName: electron
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
icon: icon.icns
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
# The bundled WCDB bridge accepts Electron as its internal host name. The
# public app name and bundle identifier remain WechatExplorer-specific.
# The bundled WCDB bridge still uses Electron as its internal executable
# compatibility name; the public product and bundle identity are TraceMemo.
CFBundleName: Electron
CFBundleDisplayName: WechatExplorer
CFBundleDisplayName: TraceMemo
NSCameraUsageDescription: Application requests access to the device's camera.
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-${arch}.${ext}
linux:
icon: icon.png
target:
- AppImage
- snap
@@ -52,5 +69,7 @@ appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
provider: github
owner: Wxw-Gu
repo: WechatExplorer
releaseType: release
+15 -1
View File
@@ -3,7 +3,21 @@ import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {},
main: {
build: {
rollupOptions: {
input: {
index: resolve('src/main/index.ts'),
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
},
output: {
entryFileNames: '[name].js'
},
external: ['koffi', 'sherpa-onnx-node']
}
}
},
preload: {},
renderer: {
resolve: {
+139
View File
@@ -0,0 +1,139 @@
"use strict";
const electron = require("electron");
const preload = require("@electron-toolkit/preload");
const api = {
writeAppLog: (entry) => electron.ipcRenderer.invoke("app-log:write", entry),
getAppLogPath: () => electron.ipcRenderer.invoke("app-log:getPath"),
revealAppLog: () => electron.ipcRenderer.invoke("app-log:reveal"),
getAppUpdateState: () => electron.ipcRenderer.invoke("app-update:getState"),
checkAppUpdate: () => electron.ipcRenderer.invoke("app-update:check"),
downloadAppUpdate: () => electron.ipcRenderer.invoke("app-update:download"),
installAppUpdate: () => electron.ipcRenderer.invoke("app-update:install"),
onAppUpdateState: (callback) => {
const listener = (_event, state) => callback(state);
electron.ipcRenderer.on("app-update:state", listener);
return () => electron.ipcRenderer.removeListener("app-update:state", listener);
},
getCacheSummary: () => electron.ipcRenderer.invoke("cache:getSummary"),
clearCache: (scope) => electron.ipcRenderer.invoke("cache:clear", scope),
initDb: (key) => electron.ipcRenderer.invoke("db:init", key),
getBootstrapCache: () => electron.ipcRenderer.invoke("db:getBootstrapCache"),
getStartupCache: () => electron.ipcRenderer.invoke("db:getStartupCache"),
getContacts: (filter) => electron.ipcRenderer.invoke("db:getContacts", filter),
getContactAvatars: (usernames) => electron.ipcRenderer.invoke("db:getContactAvatars", usernames),
getCachedMessages: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessages", userMd5, startTime, endTime),
getCachedMessagePage: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessagePage", userMd5, startTime, endTime),
getMessages: (userMd5, startTime, endTime, options) => electron.ipcRenderer.invoke("db:getMessages", userMd5, startTime, endTime, options),
getGroupSnapshot: (userMd5) => electron.ipcRenderer.invoke("db:getGroupSnapshot", userMd5),
search: (keyword) => electron.ipcRenderer.invoke("db:search", keyword),
aiChat: (messages, options) => electron.ipcRenderer.invoke("ai:chat", messages, options),
listAIProviders: () => electron.ipcRenderer.invoke("ai:listProviders"),
getAIRuntimeConfig: () => electron.ipcRenderer.invoke("ai:getRuntimeConfig"),
saveAIProvider: (provider) => electron.ipcRenderer.invoke("ai:saveProvider", provider),
deleteAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:deleteProvider", providerId),
setDefaultAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:setDefaultProvider", providerId),
testAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:testProvider", providerId),
testAIVision: (request) => electron.ipcRenderer.invoke("ai:testVision", request),
migrateLegacyAIConfig: (config) => electron.ipcRenderer.invoke("ai:migrateLegacy", config),
copyImage: (base64String) => electron.ipcRenderer.invoke("copy-image", base64String),
getVoiceData: (sessionId, localId, createTime, svrId) => electron.ipcRenderer.invoke("db:getVoiceData", sessionId, localId, createTime, svrId),
parseMessage: (content, messageType) => electron.ipcRenderer.invoke("db:parseMessage", content, messageType),
getImage: (imageMd5, imageDatNameOrThumb, sessionId, options) => electron.ipcRenderer.invoke("db:getImage", imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes) => electron.ipcRenderer.invoke("db:getVideo", hashes),
getSticker: (cdnUrl, md5) => electron.ipcRenderer.invoke("db:getSticker", cdnUrl, md5),
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
onExportProgress: (callback) => {
const listener = (_event, progress) => callback(progress);
electron.ipcRenderer.on("export:progress", listener);
return () => electron.ipcRenderer.removeListener("export:progress", listener);
},
exportGroupReport: (request) => electron.ipcRenderer.invoke("report:export", request),
listGeneratedReports: () => electron.ipcRenderer.invoke("report:listGenerated"),
saveGeneratedReport: (request) => electron.ipcRenderer.invoke("report:saveGenerated", request),
deleteGeneratedReport: (reportId) => electron.ipcRenderer.invoke("report:deleteGenerated", reportId),
revealGroupReport: (filePath) => electron.ipcRenderer.invoke("report:reveal", filePath),
getSavedDbKey: () => electron.ipcRenderer.invoke("key:getSavedDbKey"),
getDatabaseKeyEnvironment: () => electron.ipcRenderer.invoke("key:getEnvironment"),
readDatabaseKeyClipboard: () => electron.ipcRenderer.invoke("key:readClipboardDbKey"),
autoGetDbKey: (options) => electron.ipcRenderer.invoke("key:autoGetDbKey", options),
autoGetImageKey: (options) => electron.ipcRenderer.invoke("key:autoGetImageKey", options),
getImageKeyConfig: () => electron.ipcRenderer.invoke("image:getConfig"),
getImageDecryptionStatus: () => electron.ipcRenderer.invoke("image:getStatus"),
saveImageKeyConfig: (request) => electron.ipcRenderer.invoke("image:saveConfig", request),
testImageDecryption: (request) => electron.ipcRenderer.invoke("image:testConfig", request),
clearImageKeyConfig: () => electron.ipcRenderer.invoke("image:clearConfig"),
pasteAndSaveDbKey: () => electron.ipcRenderer.invoke("key:pasteAndSaveDbKey"),
saveDbKey: (key) => electron.ipcRenderer.invoke("key:saveDbKey", key),
clearSavedDbKey: () => electron.ipcRenderer.invoke("key:clearSavedDbKey"),
onWcdbChange: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("wcdb-change", listener);
return () => electron.ipcRenderer.removeListener("wcdb-change", listener);
},
onDbKeyStatus: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("key:dbKeyStatus", listener);
return () => electron.ipcRenderer.removeListener("key:dbKeyStatus", listener);
},
onImageKeyStatus: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("key:imageKeyStatus", listener);
return () => electron.ipcRenderer.removeListener("key:imageKeyStatus", listener);
},
getSettings: () => electron.ipcRenderer.invoke("settings:get"),
setSettings: (patch) => electron.ipcRenderer.invoke("settings:set", patch),
getSelf: () => electron.ipcRenderer.invoke("settings:getSelf"),
testConnection: (key, accountRoot) => electron.ipcRenderer.invoke("db:testConnection", key, accountRoot),
reopenWithRoot: (accountRoot) => electron.ipcRenderer.invoke("db:reopenWithRoot", accountRoot),
selectDbRoot: () => electron.ipcRenderer.invoke("settings:selectDbRoot"),
openAccountRoot: () => electron.ipcRenderer.invoke("settings:openAccountRoot"),
disconnectDb: (options) => electron.ipcRenderer.invoke("db:disconnect", options),
apiStatus: () => electron.ipcRenderer.invoke("api:getStatus"),
apiStart: (host, port) => electron.ipcRenderer.invoke("api:start", host, port),
apiStop: () => electron.ipcRenderer.invoke("api:stop"),
apiToggle: (enabled) => electron.ipcRenderer.invoke("api:toggle", enabled),
getReaderSkillStatus: () => electron.ipcRenderer.invoke("api:skillStatus"),
readReaderSkill: () => electron.ipcRenderer.invoke("api:readSkill"),
revealReaderSkill: () => electron.ipcRenderer.invoke("api:revealSkill"),
openReaderSkillGithub: () => electron.ipcRenderer.invoke("api:openSkillGithub"),
testLocalApiRequest: (request) => electron.ipcRenderer.invoke("api:testLocalRequest", request),
copyText: (text) => electron.ipcRenderer.invoke("api:copyText", text),
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
imageListCandidates: (query) => electron.ipcRenderer.invoke("image:listCandidates", query),
imageAnalyze: (request) => electron.ipcRenderer.invoke("image:analyze", request),
getImageInsight: (imageHash) => electron.ipcRenderer.invoke("image:getInsight", imageHash),
listImageInsights: (sessionId, limit) => electron.ipcRenderer.invoke("image:listInsights", sessionId, limit),
getAgentHubStatus: () => electron.ipcRenderer.invoke("agent-hub:getStatus"),
getAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:getLogs"),
clearAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:clearLogs"),
startAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:startLogin"),
cancelAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:cancelLogin"),
reconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:reconnect"),
disconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:disconnect"),
selectAgentHubTestImage: () => electron.ipcRenderer.invoke("agent-hub:selectTestImage"),
onAgentHubStatus: (callback) => {
const listener = (_event, status) => callback(status);
electron.ipcRenderer.on("agent-hub:status", listener);
return () => electron.ipcRenderer.removeListener("agent-hub:status", listener);
},
onAgentHubLog: (callback) => {
const listener = (_event, entry) => callback(entry);
electron.ipcRenderer.on("agent-hub:log", listener);
return () => electron.ipcRenderer.removeListener("agent-hub:log", listener);
}
};
if (process.contextIsolated) {
try {
electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI);
electron.contextBridge.exposeInMainWorld("api", api);
} catch (error) {
console.error(error);
}
} else {
window.electron = preload.electronAPI;
window.api = api;
}
+83 -17
View File
@@ -1,69 +1,135 @@
{
"name": "wechatexplorer",
"version": "2.0.1",
"description": "mac 版本获取微信聊天记录, AI群聊总结助手",
"name": "tracememo",
"version": "2.2.0",
"packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
"keywords": [
"wechat",
"chat",
"wechat chat",
"wechat history",
"mac微信",
"windows微信",
"微信聊天记录",
"AI群聊总结助手"
"微信聊天记录搜索",
"微信AI",
"微信机器人",
"AI聊天搜索",
"AI群聊总结",
"本地AI"
],
"author": "Qingmao",
"repository": {
"type": "git",
"url": "https://github.com/Wxw-Gu/TraceMemo.git"
},
"main": "./out/main/index.js",
"scripts": {
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
"format": "prettier --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
"cp:env": "node scripts/ensure-env.cjs",
"prepare:env": "node scripts/ensure-env.cjs",
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:component": "vitest run --config vitest.component.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"benchmark:knowledge": "vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
"benchmark:knowledge:capacity": "cross-env KNOWLEDGE_CAPACITY=1 vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
"test:e2e:build": "electron-vite build",
"test:knowledge-worker": "pnpm test:e2e:build && node scripts/test-knowledge-worker.cjs",
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
"build:native-services": "npm run build:wechat-connector",
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
"build:mac:x64": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@radix-ui/react-popover": "^1.1.23",
"@tanstack/react-virtual": "^3.14.6",
"archiver": "^8.0.0",
"cross-env": "^10.1.0",
"electron-updater": "^6.6.2",
"ffmpeg-static": "5.3.0",
"fs-extra": "^11.3.2",
"fzstd": "^0.1.1",
"koffi": "^2.9.0",
"jsonrepair": "^3.15.0",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"silk-wasm": "^3.7.1"
"sherpa-onnx-node": "1.13.3",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.62.1",
"@rollup/rollup-darwin-arm64": "^4.62.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/archiver": "^8.0.0",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"@vitest/coverage-v8": "^4.1.10",
"electron": "^43.0.0",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"jsdom": "^30.0.1",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sass": "^1.102.0",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.2.6",
"vitest": "^4.1.10"
},
"pnpm": {
"supportedArchitectures": {
"os": [
"current",
"win32"
],
"cpu": [
"current",
"x64"
]
},
"onlyBuiltDependencies": [
"electron",
"esbuild"
"esbuild",
"ffmpeg-static"
]
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
testMatch: /.*\.spec\.ts/,
timeout: 45_000,
expect: { timeout: 8_000 },
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
outputDir: 'test-results',
snapshotPathTemplate: 'tests/e2e/__screenshots__/{platform}/{testFilePath}/{arg}{ext}',
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
})
+2159 -179
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.
+449 -88
View File
@@ -27,13 +27,13 @@
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
padding: 20px 14px 34px;
}
.hero,
.card,
.section {
.section,
.card {
background: #fff;
border-radius: 16px;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
@@ -41,20 +41,35 @@
}
.hero-top {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
margin: 0;
font-size: 23px;
line-height: 1.2;
margin: 0 0 8px;
font-weight: 900;
}
.sub {
margin-top: 8px;
color: #667085;
font-size: 13px;
line-height: 1.5;
line-height: 1.55;
}
.mode-tag {
display: inline-flex;
margin-top: 10px;
padding: 4px 10px;
border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
}
.avatar-grid {
width: 58px;
@@ -71,6 +86,49 @@
border-radius: 50%;
object-fit: cover;
}
.hero-headline {
margin-top: 14px;
padding: 14px;
border-radius: 16px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
}
.hero-headline b {
display: block;
font-size: 17px;
color: #076c39;
}
.hero-headline p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.65;
color: #1f2933;
}
.hero-inline-notes {
display: grid;
gap: 8px;
margin-top: 10px;
}
.hero-note,
.hero-status {
padding: 10px 12px;
border-radius: 12px;
font-size: 12px;
line-height: 1.5;
}
.hero-status {
margin-top: 10px;
background: #f7faf9;
color: #076c39;
font-weight: 700;
}
.hero-note.takeaway {
background: #eef8f2;
color: #076c39;
}
.hero-note.pending {
background: #fff8e8;
color: #8a5a00;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -87,22 +145,23 @@
display: block;
font-size: 18px;
color: #07a352;
line-height: 1.25;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 14px;
margin-top: 18px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
margin-bottom: 14px;
font-size: 19px;
font-weight: 900;
margin-bottom: 12px;
}
.section-title::before {
content: '';
@@ -111,11 +170,23 @@
border-radius: 99px;
background: #07c160;
}
.section-subtitle {
margin: 10px 0 6px;
color: #667085;
font-size: 12px;
font-weight: 700;
}
.section-more {
margin-top: 10px;
color: #98a2b3;
font-size: 11px;
text-align: right;
}
.card {
padding: 14px;
margin-top: 10px;
box-shadow: none;
border: 1px solid #edf0f2;
box-shadow: none;
}
.topic-title-row {
display: flex;
@@ -124,17 +195,17 @@
gap: 8px;
}
.topic-title-row h3 {
margin: 0;
font-size: 16px;
line-height: 1.35;
margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
@@ -149,19 +220,44 @@
background: #eef5ff;
color: #1677ff;
}
.red {
background: #fff1f0;
color: #ff4d4f;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.65;
margin: 10px 0 0;
}
.topic-conclusions {
display: grid;
gap: 8px;
margin-top: 10px;
}
.topic-conclusion {
padding: 8px 10px;
border-radius: 10px;
background: #edf9f1;
color: #076c39;
font-size: 12px;
line-height: 1.5;
font-weight: 700;
}
.topic-inline-image {
display: grid;
grid-template-columns: 76px 1fr;
gap: 10px;
margin-top: 10px;
padding: 10px;
border-radius: 12px;
background: #f7faf9;
}
.topic-inline-image img {
width: 76px;
height: 76px;
border-radius: 10px;
object-fit: cover;
}
.participants {
display: flex;
@@ -173,7 +269,6 @@
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
@@ -182,7 +277,6 @@
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
@@ -204,17 +298,6 @@
background: #f2f4f7;
color: #667085;
}
.resource {
padding: 11px 12px;
background: #f7f8fa;
border-radius: 12px;
margin-top: 8px;
font-size: 13px;
line-height: 1.55;
}
.resource b {
color: #1677ff;
}
.important-card {
display: flex;
gap: 10px;
@@ -239,9 +322,9 @@
color: #1f2933;
}
.important-text {
margin-top: 5px;
font-size: 13px;
line-height: 1.55;
margin-top: 5px;
}
.important-note {
margin-top: 8px;
@@ -253,6 +336,34 @@
font-size: 12px;
line-height: 1.45;
}
.action-grid {
display: grid;
gap: 10px;
}
.action-card {
border-radius: 14px;
padding: 12px;
}
.todo-card {
background: #eef5ff;
}
.unresolved-card {
background: #fff8e8;
}
.action-card b {
display: block;
color: #1f2933;
font-size: 14px;
}
.action-card div {
margin-top: 6px;
font-size: 12px;
line-height: 1.55;
color: #485465;
}
.action-note {
color: #667085;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
@@ -283,49 +394,180 @@
line-height: 1.5;
}
.quote-note {
background: #fff8e1;
border-radius: 10px;
padding: 9px 10px;
margin-top: 10px;
padding: 9px 10px;
border-radius: 10px;
background: #fff8e1;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card {
background: #f8fafc;
border-radius: 14px;
padding: 12px;
.qa-card,
.resource {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.qa-card b {
.qa-card b,
.resource b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div {
.qa-card div,
.resource {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.bar-row {
.storyline-card,
.chain-card {
background: #f8fafc;
}
.storyline-steps {
display: grid;
grid-template-columns: 82px 1fr;
gap: 8px;
align-items: center;
margin-top: 9px;
margin-top: 10px;
}
.storyline-step {
display: grid;
grid-template-columns: 50px 1fr;
gap: 10px;
}
.storyline-step span {
color: #8a94a6;
font-size: 12px;
}
.bar {
height: 10px;
background: #edf1f5;
border-radius: 999px;
overflow: hidden;
.storyline-step b {
font-size: 13px;
line-height: 1.5;
}
.bar i {
display: block;
height: 100%;
background: #07c160;
.chain-flow {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 10px;
}
.chain-flow span {
display: inline-flex;
align-items: center;
padding: 6px 9px;
border-radius: 999px;
background: #eef8f2;
color: #076c39;
font-size: 12px;
font-weight: 700;
}
.chain-flow i {
font-style: normal;
color: #98a2b3;
}
.gallery-card {
display: grid;
grid-template-columns: 112px 1fr;
gap: 12px;
margin-top: 10px;
padding: 12px;
background: #f7faf9;
border-radius: 14px;
}
.gallery-image {
width: 112px;
height: 112px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.gallery-stats {
display: inline-flex;
margin-top: 7px;
padding: 4px 8px;
border-radius: 999px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
font-weight: 700;
}
.badge-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* AI 图片识别板块 */
.vision-card {
display: grid;
grid-template-columns: 132px 1fr;
gap: 14px;
margin-top: 10px;
padding: 12px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
border: 1px solid #d6efde;
border-radius: 14px;
}
.vision-image {
width: 132px;
height: 132px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.vision-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
color: #1f2933;
}
.vision-ocr {
margin-top: 6px;
padding: 6px 10px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
border-radius: 8px;
word-break: break-all;
}
.vision-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vision-tag {
display: inline-block;
padding: 3px 8px;
background: #07c160;
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
}
.vision-label {
margin-top: 6px;
font-size: 10px;
color: #07a352;
font-weight: 600;
}
.badge-card {
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
border: 1px solid #edf0f2;
border-radius: 14px;
padding: 12px;
}
.badge-card b {
display: block;
margin-top: 8px;
font-size: 15px;
}
.badge-card p {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.55;
}
.data-grid {
display: grid;
gap: 12px;
}
.rank {
display: flex;
@@ -334,6 +576,9 @@
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank:last-child {
border-bottom: none;
}
.rank img {
width: 30px;
height: 30px;
@@ -386,7 +631,58 @@
color: #8a94a6;
}
.empty-section {
display: none;
display: none !important;
}
.compact .report {
padding-top: 18px;
}
.compact .section {
margin-top: 12px;
padding: 14px;
}
.compact .card {
padding: 11px;
}
.compact .important-card,
.compact .gallery-card,
.compact .chat-block {
padding: 10px;
}
.compact .section-title {
margin-bottom: 9px;
font-size: 17px;
}
.compact .hero-headline p,
.compact .card p,
.compact .important-text,
.compact .chat-bubble {
line-height: 1.5;
}
.compact .participants,
.compact .keywords,
.compact .hero-inline-notes {
gap: 6px;
}
.compact .topic-conclusions {
gap: 6px;
}
.compact .topic-inline-image {
grid-template-columns: 64px 1fr;
padding: 8px;
}
.compact .topic-inline-image img {
width: 64px;
height: 64px;
}
.compact .stats {
gap: 6px;
margin-top: 14px;
}
.compact .stat {
padding: 8px 6px;
}
.compact .stat b {
font-size: 17px;
}
@media (max-width: 430px) {
html,
@@ -398,75 +694,140 @@
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
</style>
</head>
<body>
<body class="{{REPORT_MODE_CLASS}}">
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="hero-headline">
<b>{{HERO_HEADLINE}}</b>
<p>{{HERO_SUMMARY}}</p>
</div>
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
<div class="hero-inline-notes">
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>时间跨度</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
</div>
</header>
<section class="section topics">
<section class="section {{TOPICS_EMPTY_CLASS}}">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
{{TOPICS_MORE_NOTE}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
<section class="section {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息</div>
{{IMPORTANT_MESSAGES}}
{{MESSAGES_MORE_NOTE}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
<section class="section {{ACTIONS_EMPTY_CLASS}}">
<div class="section-title">待办事项和未解决问题</div>
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
{{ACTIONS_MORE_NOTE}}
</section>
<section class="section {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">今日名场面</div>
{{QUOTE_BLOCKS}}
{{QUOTES_MORE_NOTE}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
<div class="section-title">今日群数据</div>
<div class="data-grid">
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线</b>{{ACTIVITY_TIMELINE}}</p>
<p><b>活跃时</b>{{ACTIVITY_TIMELINE}}</p>
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
</div>
</div>
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
<div class="section-title">关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
{{KEYWORDS_MORE_NOTE}}
</section>
<section class="section {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
{{RESOURCES_MORE_NOTE}}
</section>
<section class="section {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
{{QA_MORE_NOTE}}
</section>
<section class="section {{STORYLINES_EMPTY_CLASS}}">
<div class="section-title">今日剧情时间线</div>
{{STORYLINE_CARDS}}
{{STORYLINES_MORE_NOTE}}
</section>
<section class="section {{REVERSALS_EMPTY_CLASS}}">
<div class="section-title">群聊反转现场</div>
{{REVERSAL_CARDS}}
{{REVERSALS_MORE_NOTE}}
</section>
<section class="section {{VISION_EMPTY_CLASS}}">
<div class="section-title">{{VISION_TITLE}}</div>
{{VISION_CARDS}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div>
{{VOICE_CARDS}}
{{VOICE_MORE_NOTE}}
</section>
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
<div class="section-title">语音时长榜</div>
<div class="card">{{VOICE_RANK_CARDS}}</div>
</section>
<section class="section {{BADGES_EMPTY_CLASS}}">
<div class="section-title">今日临时人设</div>
<div class="badge-grid">{{BADGE_CARDS}}</div>
{{BADGES_MORE_NOTE}}
</section>
<section class="section {{CHAINS_EMPTY_CLASS}}">
<div class="section-title">话题参与链路</div>
{{CHAIN_CARDS}}
{{CHAINS_MORE_NOTE}}
</section>
<footer class="footer">
+603
View File
@@ -0,0 +1,603 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{REPORT_TITLE}}</title>
<style>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
html {
width: 430px;
scrollbar-width: none;
}
body {
margin: 0;
width: 430px;
background: #f3f5f7;
color: #1f2933;
font-family:
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
}
.hero,
.card,
.section {
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
padding: 20px;
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
font-size: 23px;
line-height: 1.2;
margin: 0 0 8px;
font-weight: 900;
}
.sub {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
.record-note {
color: #485465;
font-weight: 650;
}
.overview {
margin-top: 2px;
}
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
flex: 0 0 auto;
}
.avatar-grid.avatar-count-1 {
width: 28px;
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
}
.avatar-grid.empty-section {
display: none;
}
.avatar-grid img,
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: #f7faf9;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
min-width: 0;
overflow: hidden;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
white-space: nowrap;
line-height: 1.25;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 14px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 900;
margin-bottom: 12px;
}
.section-title::before {
content: '';
width: 5px;
height: 20px;
border-radius: 99px;
background: #07c160;
}
.card {
padding: 14px;
margin-top: 10px;
box-shadow: none;
border: 1px solid #edf0f2;
}
.topic-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.topic-title-row h3 {
font-size: 16px;
line-height: 1.35;
margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.hot {
background: #fff4e5;
color: #d46b08;
}
.blue {
background: #eef5ff;
color: #1677ff;
}
.red {
background: #fff1f0;
color: #ff4d4f;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
font-size: 13px;
line-height: 1.65;
margin: 10px 0 0;
}
.participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
}
.person-chip img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.keywords span {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
}
.resource {
padding: 11px 12px;
background: #f7f8fa;
border-radius: 12px;
margin-top: 8px;
font-size: 13px;
line-height: 1.55;
}
.resource b {
color: #1677ff;
}
.important-card {
display: flex;
gap: 10px;
background: #f7faf9;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.important-card > .avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
}
.important-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #667085;
}
.important-meta b {
color: #1f2933;
}
.important-text {
font-size: 13px;
line-height: 1.55;
margin-top: 5px;
}
.important-note {
margin-top: 8px;
padding: 7px 9px;
border-left: 3px solid #07c160;
background: #fff;
border-radius: 8px;
color: #07a352;
font-size: 12px;
line-height: 1.45;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.chat-msg {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.chat-name {
font-size: 11px;
color: #667085;
margin-bottom: 4px;
}
.chat-bubble {
background: #fff;
border-radius: 4px 12px 12px 12px;
padding: 9px 10px;
font-size: 13px;
line-height: 1.5;
}
.quote-note {
background: #fff8e1;
border-radius: 10px;
padding: 9px 10px;
margin-top: 10px;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card {
background: #f8fafc;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.qa-card b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.bar-row {
display: grid;
grid-template-columns: 82px 1fr;
gap: 8px;
align-items: center;
margin-top: 9px;
font-size: 12px;
}
.bar {
height: 10px;
background: #edf1f5;
border-radius: 999px;
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: #07c160;
border-radius: 999px;
}
.rank {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank img {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
}
.rank b {
font-size: 13px;
}
.rank span {
margin-left: auto;
color: #8a94a6;
font-size: 12px;
}
.cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cloud-tags span {
padding: 6px 10px;
border-radius: 999px;
background: #f2f4f7;
color: #485465;
font-weight: 800;
}
.cloud-tags .xl {
font-size: 20px;
color: #07a352;
background: #e9f8ef;
}
.cloud-tags .lg {
font-size: 17px;
color: #1677ff;
background: #eef5ff;
}
.cloud-tags .md {
font-size: 15px;
color: #d46b08;
background: #fff4e5;
}
.footer {
padding: 16px 4px 0;
color: #98a2b3;
text-align: center;
font-size: 11px;
line-height: 1.8;
}
.muted {
color: #8a94a6;
}
.empty-section {
display: none;
}
@media (max-width: 430px) {
html,
body {
width: 100%;
}
.report {
width: 100%;
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
/* AI 图片识别板块(v1 模板) */
.vision-card {
display: grid;
grid-template-columns: 132px 1fr;
gap: 14px;
margin-top: 10px;
padding: 12px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
border: 1px solid #d6efde;
border-radius: 14px;
}
.vision-image {
width: 132px;
height: 132px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.vision-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
color: #1f2933;
}
.vision-ocr {
margin-top: 6px;
padding: 6px 10px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
border-radius: 8px;
word-break: break-all;
}
.vision-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vision-tag {
display: inline-block;
padding: 3px 8px;
background: #07c160;
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
}
.vision-label {
margin-top: 6px;
font-size: 10px;
color: #07a352;
font-weight: 600;
}
/* 热度条形图(v1 模板) */
.heat-row {
display: grid;
grid-template-columns: 80px 1fr 40px;
align-items: center;
gap: 10px;
margin-top: 8px;
font-size: 12px;
}
.heat-name {
color: #1f2933;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.heat-bar {
background: #f3f5f7;
border-radius: 999px;
height: 10px;
overflow: hidden;
}
.heat-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, #07c160 0%, #34d399 100%);
border-radius: 999px;
}
.heat-val {
color: #485465;
font-weight: 700;
font-size: 11px;
text-align: right;
}
</style>
</head>
<body>
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">
<div>{{DATE_RANGE}}</div>
<div class="record-note">{{RECORD_NOTE}}</div>
<div class="overview">{{OVERVIEW}}</div>
</div>
</div>
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
</div>
</header>
<section class="section topics">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
</section>
<section class="section vision {{VISION_EMPTY_CLASS}}">
<div class="section-title">{{VISION_TITLE}}</div>
{{VISION_CARDS}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
{{IMPORTANT_MESSAGES}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
{{QUOTE_BLOCKS}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
</div>
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
</section>
<footer class="footer">
数据来源:TraceMemo · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
</main>
</body>
</html>
+840
View File
@@ -0,0 +1,840 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{REPORT_TITLE}}</title>
<style>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
html {
width: 430px;
scrollbar-width: none;
}
body {
margin: 0;
width: 430px;
background: #f3f5f7;
color: #1f2933;
font-family:
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.report {
width: 430px;
margin: 0 auto;
padding: 20px 14px 34px;
}
.hero,
.section,
.card {
background: #fff;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
padding: 20px;
}
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
margin: 0;
font-size: 23px;
line-height: 1.2;
font-weight: 900;
}
.sub {
margin-top: 8px;
color: #667085;
font-size: 13px;
line-height: 1.55;
}
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
flex: 0 0 auto;
}
.avatar-grid.avatar-count-1 {
width: 28px;
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
}
.avatar-grid.empty-section {
display: none;
}
.avatar-grid img,
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.hero-headline {
margin-top: 14px;
padding: 14px;
border-radius: 16px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
}
.hero-headline b {
display: block;
font-size: 17px;
color: #076c39;
}
.hero-headline p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.65;
color: #1f2933;
}
.hero-inline-notes {
display: grid;
gap: 8px;
margin-top: 10px;
}
.hero-note,
.hero-status {
padding: 10px 12px;
border-radius: 12px;
font-size: 12px;
line-height: 1.5;
}
.hero-status {
margin-top: 10px;
background: #f7faf9;
color: #076c39;
font-weight: 700;
}
.hero-note.takeaway {
background: #eef8f2;
color: #076c39;
}
.hero-note.pending {
background: #fff8e8;
color: #8a5a00;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: #f7faf9;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
line-height: 1.25;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 18px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 19px;
font-weight: 900;
}
.section-title::before {
content: '';
width: 5px;
height: 20px;
border-radius: 99px;
background: #07c160;
}
.section-subtitle {
margin: 10px 0 6px;
color: #667085;
font-size: 12px;
font-weight: 700;
}
.section-more {
margin-top: 10px;
color: #98a2b3;
font-size: 11px;
text-align: right;
}
.card {
padding: 14px;
margin-top: 10px;
border: 1px solid #edf0f2;
box-shadow: none;
}
.topic-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.topic-title-row h3 {
margin: 0;
font-size: 16px;
line-height: 1.35;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.hot {
background: #fff4e5;
color: #d46b08;
}
.blue {
background: #eef5ff;
color: #1677ff;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.65;
}
.topic-conclusions {
display: grid;
gap: 8px;
margin-top: 10px;
}
.topic-conclusion {
padding: 8px 10px;
border-radius: 10px;
background: #edf9f1;
color: #076c39;
font-size: 12px;
line-height: 1.5;
font-weight: 700;
}
.topic-inline-image {
display: grid;
grid-template-columns: 76px 1fr;
gap: 10px;
margin-top: 10px;
padding: 10px;
border-radius: 12px;
background: #f7faf9;
}
.topic-inline-image img {
width: 76px;
height: 76px;
border-radius: 10px;
object-fit: cover;
}
.participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 5px;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
}
.person-chip img {
width: 24px;
height: 24px;
border-radius: 50%;
}
.person-chip b {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.keywords span {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
}
.important-card {
display: flex;
gap: 10px;
background: #f7faf9;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.important-card > .avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
}
.important-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #667085;
}
.important-meta b {
color: #1f2933;
}
.important-text {
margin-top: 5px;
font-size: 13px;
line-height: 1.55;
}
.important-note {
margin-top: 8px;
padding: 7px 9px;
border-left: 3px solid #07c160;
background: #fff;
border-radius: 8px;
color: #07a352;
font-size: 12px;
line-height: 1.45;
}
.action-grid {
display: grid;
gap: 10px;
}
.action-card {
border-radius: 14px;
padding: 12px;
}
.todo-card {
background: #eef5ff;
}
.unresolved-card {
background: #fff8e8;
}
.action-card b {
display: block;
color: #1f2933;
font-size: 14px;
}
.action-card div {
margin-top: 6px;
font-size: 12px;
line-height: 1.55;
color: #485465;
}
.action-note {
color: #667085;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.chat-msg {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.chat-name {
font-size: 11px;
color: #667085;
margin-bottom: 4px;
}
.chat-bubble {
background: #fff;
border-radius: 4px 12px 12px 12px;
padding: 9px 10px;
font-size: 13px;
line-height: 1.5;
}
.quote-note {
margin-top: 10px;
padding: 9px 10px;
border-radius: 10px;
background: #fff8e1;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card,
.resource {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.qa-card b,
.resource b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div,
.resource {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.storyline-card,
.chain-card {
background: #f8fafc;
}
.storyline-steps {
display: grid;
gap: 8px;
margin-top: 10px;
}
.storyline-step {
display: grid;
grid-template-columns: 50px 1fr;
gap: 10px;
}
.storyline-step span {
color: #8a94a6;
font-size: 12px;
}
.storyline-step b {
font-size: 13px;
line-height: 1.5;
}
.chain-flow {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 10px;
}
.chain-flow span {
display: inline-flex;
align-items: center;
padding: 6px 9px;
border-radius: 999px;
background: #eef8f2;
color: #076c39;
font-size: 12px;
font-weight: 700;
}
.chain-flow i {
font-style: normal;
color: #98a2b3;
}
.gallery-card {
display: grid;
grid-template-columns: 112px 1fr;
gap: 12px;
margin-top: 10px;
padding: 12px;
background: #f7faf9;
border-radius: 14px;
}
.gallery-image {
width: 112px;
height: 112px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.gallery-stats {
display: inline-flex;
margin-top: 7px;
padding: 4px 8px;
border-radius: 999px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
font-weight: 700;
}
.badge-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* AI 图片识别板块 */
.vision-card {
display: grid;
grid-template-columns: 132px 1fr;
gap: 14px;
margin-top: 10px;
padding: 12px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
border: 1px solid #d6efde;
border-radius: 14px;
}
.vision-image {
width: 132px;
height: 132px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.vision-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
color: #1f2933;
}
.vision-ocr {
margin-top: 6px;
padding: 6px 10px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
border-radius: 8px;
word-break: break-all;
}
.vision-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vision-tag {
display: inline-block;
padding: 3px 8px;
background: #07c160;
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
}
.vision-label {
margin-top: 6px;
font-size: 10px;
color: #07a352;
font-weight: 600;
}
.badge-card {
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
border: 1px solid #edf0f2;
border-radius: 14px;
padding: 12px;
}
.badge-card b {
display: block;
margin-top: 8px;
font-size: 15px;
}
.badge-card p {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.55;
}
.data-grid {
display: grid;
gap: 12px;
}
.rank {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank:last-child {
border-bottom: none;
}
.rank img {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
}
.rank b {
font-size: 13px;
}
.rank span {
margin-left: auto;
color: #8a94a6;
font-size: 12px;
}
.cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cloud-tags span {
padding: 6px 10px;
border-radius: 999px;
background: #f2f4f7;
color: #485465;
font-weight: 800;
}
.cloud-tags .xl {
font-size: 20px;
color: #07a352;
background: #e9f8ef;
}
.cloud-tags .lg {
font-size: 17px;
color: #1677ff;
background: #eef5ff;
}
.cloud-tags .md {
font-size: 15px;
color: #d46b08;
background: #fff4e5;
}
.footer {
padding: 16px 4px 0;
color: #98a2b3;
text-align: center;
font-size: 11px;
line-height: 1.8;
}
.muted {
color: #8a94a6;
}
.empty-section {
display: none !important;
}
.compact .report {
padding-top: 18px;
}
.compact .section {
margin-top: 12px;
padding: 14px;
}
.compact .card {
padding: 11px;
}
.compact .important-card,
.compact .gallery-card,
.compact .chat-block {
padding: 10px;
}
.compact .section-title {
margin-bottom: 9px;
font-size: 17px;
}
.compact .hero-headline p,
.compact .card p,
.compact .important-text,
.compact .chat-bubble {
line-height: 1.5;
}
.compact .participants,
.compact .keywords,
.compact .hero-inline-notes {
gap: 6px;
}
.compact .topic-conclusions {
gap: 6px;
}
.compact .topic-inline-image {
grid-template-columns: 64px 1fr;
padding: 8px;
}
.compact .topic-inline-image img {
width: 64px;
height: 64px;
}
.compact .stats {
gap: 6px;
margin-top: 14px;
}
.compact .stat {
padding: 8px 6px;
}
.compact .stat b {
font-size: 17px;
}
@media (max-width: 430px) {
html,
body {
width: 100%;
}
.report {
width: 100%;
padding-left: 12px;
padding-right: 12px;
}
}
</style>
</head>
<body class="{{REPORT_MODE_CLASS}}">
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
</div>
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
</div>
<div class="hero-headline">
<b>{{HERO_HEADLINE}}</b>
<p>{{HERO_SUMMARY}}</p>
</div>
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
<div class="hero-inline-notes">
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
</div>
</header>
<section class="section {{TOPICS_EMPTY_CLASS}}">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
{{TOPICS_MORE_NOTE}}
</section>
<section class="section {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息</div>
{{IMPORTANT_MESSAGES}}
{{MESSAGES_MORE_NOTE}}
</section>
<section class="section {{ACTIONS_EMPTY_CLASS}}">
<div class="section-title">待办事项和未解决问题</div>
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
{{ACTIONS_MORE_NOTE}}
</section>
<section class="section {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">今日名场面</div>
{{QUOTE_BLOCKS}}
{{QUOTES_MORE_NOTE}}
</section>
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
<div class="section-title">今日群数据</div>
<div class="data-grid">
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
</div>
</div>
</section>
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
<div class="section-title">关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
{{KEYWORDS_MORE_NOTE}}
</section>
<section class="section {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
{{RESOURCES_MORE_NOTE}}
</section>
<section class="section {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
{{QA_MORE_NOTE}}
</section>
<section class="section {{STORYLINES_EMPTY_CLASS}}">
<div class="section-title">今日剧情时间线</div>
{{STORYLINE_CARDS}}
{{STORYLINES_MORE_NOTE}}
</section>
<section class="section {{REVERSALS_EMPTY_CLASS}}">
<div class="section-title">群聊反转现场</div>
{{REVERSAL_CARDS}}
{{REVERSALS_MORE_NOTE}}
</section>
<section class="section {{VISION_EMPTY_CLASS}}">
<div class="section-title">{{VISION_TITLE}}</div>
{{VISION_CARDS}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div>
{{VOICE_CARDS}}
{{VOICE_MORE_NOTE}}
</section>
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
<div class="section-title">语音时长榜</div>
<div class="card">{{VOICE_RANK_CARDS}}</div>
</section>
<section class="section {{BADGES_EMPTY_CLASS}}">
<div class="section-title">今日临时人设</div>
<div class="badge-grid">{{BADGE_CARDS}}</div>
{{BADGES_MORE_NOTE}}
</section>
<section class="section {{CHAINS_EMPTY_CLASS}}">
<div class="section-title">话题参与链路</div>
{{CHAIN_CARDS}}
{{CHAINS_MORE_NOTE}}
</section>
<footer class="footer">
数据来源:TraceMemo · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
</main>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+146 -1
View File
@@ -1,15 +1,153 @@
const { existsSync, renameSync } = require('node:fs')
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { chmodSync, existsSync, renameSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const asar = require('@electron/asar')
const COMPATIBILITY_NAME = 'Electron'
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
const REQUIRED_RUNTIME_PACKAGES = [
'@electron-toolkit/preload',
'@electron-toolkit/utils',
'archiver',
'electron-updater',
'ffmpeg-static',
'fs-extra',
'jsonrepair',
'koffi'
]
function getRuntimeResources(context) {
const productName = context.packager.appInfo.productFilename
return context.electronPlatformName === 'darwin'
? path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources')
: path.join(context.appOutDir, 'resources')
}
function validateSilkWasmRuntime(runtimeResources) {
const packagePath = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules', 'silk-wasm')
const requiredFiles = [
path.join(packagePath, 'package.json'),
path.join(packagePath, 'lib', 'index.cjs'),
path.join(packagePath, 'lib', 'silk.wasm')
]
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
if (missingFiles.length > 0) {
throw new Error(`Missing unpacked silk-wasm runtime: ${missingFiles.join(', ')}`)
}
}
function validateFfmpegRuntime(runtimeResources, platform = process.platform) {
const executable = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const ffmpegPath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
'ffmpeg-static',
executable
)
if (!existsSync(ffmpegPath)) {
throw new Error(`Missing unpacked ffmpeg-static runtime: ${ffmpegPath}`)
}
if (platform !== 'win32') chmodSync(ffmpegPath, 0o755)
return ffmpegPath
}
function validateSherpaRuntime(runtimeResources, platform, arch) {
const platformName = platform === 'win32' ? 'win' : platform
const basePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
'sherpa-onnx-node'
)
const nativePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
`sherpa-onnx-${platformName}-${arch}`
)
const requiredFiles = [
path.join(basePath, 'package.json'),
path.join(basePath, 'sherpa-onnx.js'),
path.join(nativePath, 'package.json'),
path.join(nativePath, 'sherpa-onnx.node')
]
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
if (missingFiles.length > 0) {
throw new Error(`Missing unpacked sherpa-onnx runtime: ${missingFiles.join(', ')}`)
}
}
function normalizeBuilderArch(arch) {
if (typeof arch === 'string') return arch
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
}
function validateAsarRuntimeDependencies(runtimeResources) {
const asarPath = path.join(runtimeResources, 'app.asar')
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
// @electron/asar returns platform-native separators. Normalize to POSIX
// paths so validation behaves consistently on Windows and macOS/Linux.
const entries = new Set(asar.listPackage(asarPath).map((entry) => entry.replaceAll('\\', '/')))
const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter(
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
)
if (missingPackages.length > 0) {
throw new Error(
`Missing packaged runtime dependencies: ${missingPackages.join(', ')}. ` +
'Use pnpm 7.33.7 so electron-builder can read pnpm-lock.yaml.'
)
}
}
function setPlistValue(plistPath, key, value) {
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
}
function validateReaderSkillRuntime(runtimeResources) {
const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
if (!existsSync(skillPath)) {
throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`)
}
return skillPath
}
exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context)
validateAsarRuntimeDependencies(runtimeResources)
validateReaderSkillRuntime(runtimeResources)
validateSilkWasmRuntime(runtimeResources)
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
validateSherpaRuntime(
runtimeResources,
context.electronPlatformName,
normalizeBuilderArch(context.arch)
)
if (context.electronPlatformName === 'darwin') {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
stdio: 'ignore'
})
}
if (context.electronPlatformName === 'win32') {
const koffiNative = path.join(
context.appOutDir,
'resources',
'app.asar.unpacked',
'node_modules',
'@koromix',
'koffi-win32-x64',
'win32_x64',
'koffi.node'
)
if (!existsSync(koffiNative)) {
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
}
return
}
if (context.electronPlatformName !== 'darwin') return
const productName = context.packager.appInfo.productFilename
@@ -52,3 +190,10 @@ exports.default = async function afterPack(context) {
setPlistValue(plistPath, 'CFBundleName', targetName)
}
}
exports.getRuntimeResources = getRuntimeResources
exports.validateAsarRuntimeDependencies = validateAsarRuntimeDependencies
exports.validateReaderSkillRuntime = validateReaderSkillRuntime
exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
function normalizePlatform(value) {
if (value === 'win32' || value === 'windows') return 'windows'
if (value === 'darwin' || value === 'macos') return 'darwin'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported connector platform: ${value}`)
}
function normalizeArch(value) {
if (value === 'x64' || value === 'amd64') return 'amd64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported connector architecture: ${value}`)
}
function detectHostArch() {
if (process.platform !== 'darwin') return process.arch
try {
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
encoding: 'utf8'
}).trim()
return arm64Supported === '1' ? 'arm64' : process.arch
} catch {
return process.arch
}
}
function parseTargets() {
const platformArg = process.argv.indexOf('--platform')
const archArg = process.argv.indexOf('--arch')
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
return platforms.flatMap((platform) =>
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
)
}
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
}
for (const target of parseTargets()) {
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
const outputDir = path.join(outputRoot, directoryName)
const outputPath = path.join(
outputDir,
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
)
fs.rmSync(outputDir, { recursive: true, force: true })
fs.mkdirSync(outputDir, { recursive: true })
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
cwd: sourceDir,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
stdio: 'inherit'
})
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
}
+13
View File
@@ -0,0 +1,13 @@
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const source = path.join(root, '.env.example')
const target = path.join(root, '.env')
if (!fs.existsSync(source) || fs.existsSync(target)) {
process.exit(0)
}
fs.copyFileSync(source, target)
console.log('[ensure-env] created .env from .env.example')
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# TraceMemo v2.2.0 Local HTTP API 手动验收脚本
# 仅用于 macOS Terminal;不会写入或输出真实 API Token。
set -u
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:6131}"
API_BASE_URL="${API_BASE_URL%/}"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tracememo-api-test.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
PASS_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0
pass() { PASS_COUNT=$((PASS_COUNT + 1)); printf 'PASS %s\n' "$1"; }
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); printf 'FAIL %s%s\n' "$1" "${2:+ ($2)}"; }
skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); printf 'SKIP %s\n' "$1"; }
printf 'TraceMemo Local HTTP API 手动测试\n'
printf 'API 地址: %s\n\n' "$API_BASE_URL"
read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN
printf '\n'
if [[ -z "$API_TOKEN" ]]; then
printf 'Token 不能为空。\n'
exit 2
fi
request() {
local method="$1" path="$2" auth="$3" origin="$4" body="${5:-}"
local out="$TMP_DIR/body" headers="$TMP_DIR/headers" err="$TMP_DIR/error"
local -a args=(--silent --show-error --max-time 10 -X "$method" -D "$headers" -o "$out" -w '%{http_code}')
[[ "$auth" == 1 ]] && args+=(-H "Authorization: Bearer $API_TOKEN")
[[ "$auth" == invalid ]] && args+=(-H 'Authorization: Bearer invalid')
[[ "$auth" == malformed ]] && args+=(-H 'Authorization: abc')
[[ "$auth" == bearer-only ]] && args+=(-H 'Authorization: Bearer')
[[ -n "$origin" ]] && args+=(-H "Origin: $origin")
if [[ -n "$body" ]]; then args+=(-H 'Content-Type: application/json' --data "$body"); fi
: >"$out"
: >"$headers"
: >"$err"
local status
status="$(curl "${args[@]}" "$API_BASE_URL$path" 2>"$err")"
CURL_STATUS="$status"
CURL_BODY="$(<"$out")"
CURL_HEADERS="$(<"$headers")"
}
expect_status() {
local name="$1" expected="$2" actual="$3"
if [[ "$actual" == "$expected" ]]; then pass "$name ($actual)"; else fail "$name" "期望 ${expected},实际 ${actual:-000}"; fi
}
printf '%s\n' '--- 基础鉴权 ---'
request GET /api/v1/health 0 ''
expect_status 'health 无 Token' 200 "$CURL_STATUS"
request GET /api/v1/current_time 0 ''
expect_status '受保护 endpoint 无 Token' 401 "$CURL_STATUS"
request GET /api/v1/current_time invalid ''
expect_status '错误 Token' 401 "$CURL_STATUS"
request GET /api/v1/current_time 1 ''
expect_status '正确 Token' 200 "$CURL_STATUS"
request GET /api/v1/current_time malformed ''
expect_status 'Authorization: abc' 401 "$CURL_STATUS"
request GET /api/v1/current_time bearer-only ''
expect_status 'Authorization: Bearer' 401 "$CURL_STATUS"
printf '%s\n' '--- CORS ---'
request OPTIONS /api/v1/health 0 http://localhost
expect_status 'OPTIONS / CORS localhost' 204 "$CURL_STATUS"
if [[ "$CURL_HEADERS" == *'Access-Control-Allow-Origin: http://localhost'* && "$CURL_HEADERS" == *'Access-Control-Allow-Headers: Content-Type, Authorization'* ]]; then
pass 'localhost Origin 响应头'
else
fail 'localhost Origin 响应头'
fi
request OPTIONS /api/v1/health 0 http://evil.example.com
expect_status 'evil Origin 被拒绝' 403 "$CURL_STATUS"
request GET /api/v1/health 0 ''
if [[ "$CURL_STATUS" == 200 ]]; then pass '无 Origin 的 curl 请求'; else fail '无 Origin 的 curl 请求' "实际 ${CURL_STATUS:-000}"; fi
printf '%s\n' '--- API stop 后连接测试 ---'
RUN_STOP_CHECK="${RUN_STOP_CHECK:-0}"
if [[ -t 0 && "$RUN_STOP_CHECK" != 1 ]]; then
read -r -p '现在请在 API Center 停止 API;完成后输入 y 验证连接失败,其他键跳过: ' STOP_CONFIRM
[[ "$STOP_CONFIRM" == y || "$STOP_CONFIRM" == Y ]] && RUN_STOP_CHECK=1
fi
if [[ "$RUN_STOP_CHECK" == 1 ]]; then
request GET /api/v1/health 0 ''
if [[ "$CURL_STATUS" == 000 ]]; then
pass 'API 已停止后连接失败'
else
fail 'API stop 后连接失败' "仍收到 HTTP ${CURL_STATUS:-000}"
fi
else
skip '未执行 stop 验证;也可在停止 API 后使用 RUN_STOP_CHECK=1 重新运行'
fi
printf '\n%s\n' '--- 人工验证项目(脚本不会自动操作) ---'
printf '%s\n' '1. API Center 默认隐藏 Token,点击“显示 Token”后可见,再点击隐藏。'
printf '%s\n' '2. 点击“复制 Token”,粘贴到安全位置确认复制成功;终端不要回显 Token。'
printf '%s\n' '3. 点击“重新生成 Token”并确认二次确认提示。'
printf '%s\n' '4. rotation 后,用旧 Token 请求 /api/v1/current_time 应立即返回 401。'
printf '%s\n' '5. 重启 App 后 Token 应保持不变。'
printf '%s\n' '6. 将 apiEnabled=false 后,API 应不再监听(可重新运行本脚本的 stop 测试)。'
printf '\n结果:PASS=%d FAIL=%d SKIP=%d\n' "$PASS_COUNT" "$FAIL_COUNT" "$SKIP_COUNT"
if (( FAIL_COUNT > 0 )); then exit 1; fi
exit 0
+86
View File
@@ -0,0 +1,86 @@
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
function copyIfDifferent(sourcePath, targetPath) {
const source = fs.statSync(sourcePath)
const targetExists = fs.existsSync(targetPath)
if (targetExists) {
const target = fs.statSync(targetPath)
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
return false
}
}
fs.copyFileSync(sourcePath, targetPath)
return true
}
function readOption(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
}
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
let packageRoot = ''
try {
packageRoot = path.dirname(require.resolve('ffmpeg-static/package.json'))
} catch {
return
}
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const ffmpegPath = path.join(packageRoot, executable)
if (!fs.existsSync(ffmpegPath)) {
const installScript = path.join(packageRoot, 'install.js')
console.log(
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
)
execFileSync(process.execPath, [installScript], {
stdio: 'inherit',
env: {
...process.env,
npm_config_platform: targetPlatform,
npm_config_arch: targetArch
}
})
}
if (!fs.existsSync(ffmpegPath)) {
throw new Error(`ffmpeg-static runtime download failed: ${ffmpegPath}`)
}
if (targetPlatform === 'win32') return
fs.chmodSync(ffmpegPath, 0o755)
if (process.platform === 'darwin') {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { stdio: 'ignore' })
}
}
function main() {
prepareFfmpegRuntime(readOption('platform', process.platform), readOption('arch', process.arch))
if (process.platform !== 'win32') return
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32')
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist')
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) return
let copiedCount = 0
for (const name of runtimeNames) {
const sourcePath = path.join(sourceDir, name)
const targetPath = path.join(targetDir, name)
if (!fs.existsSync(sourcePath)) continue
if (copyIfDifferent(sourcePath, targetPath)) copiedCount += 1
}
if (copiedCount > 0) {
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`)
}
}
main()
+409
View File
@@ -0,0 +1,409 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
const outputDir = path.join(os.tmpdir(), 'tracememo-report-fixtures')
const escapeHtml = (value) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
const avatarSvg = (label, color) =>
`data:image/svg+xml;base64,${Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
).toString('base64')}`
const localImagePath = '/Users/Wxw_/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/a969409112_d784/temp/RWTemp/2026-07/94ce24699a5a1d539c00a37ec8ace755.png'
const sampleImage = fs.existsSync(localImagePath)
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
: avatarSvg('图', '#dbeafe')
const avatars = {
阿宇: avatarSvg('宇', '#dcfce7'),
老周: avatarSvg('周', '#e0f2fe'),
小李: avatarSvg('李', '#fef3c7'),
'we water': avatarSvg('W', '#ede9fe'),
佩佩: avatarSvg('佩', '#fee2e2')
}
const heroNames = ['阿宇', '老周', '小李', 'we water']
const heroAvatars = heroNames
.map((name) => `<img src="${avatars[name]}" alt="${name}">`)
.join('')
const compactRequest = {
metadata: {
groupName: '技术交流',
reportDate: '2026-07-10',
dateRange: '2026-07-10 09:12-19:48',
messageCount: 382,
activeUsers: 47,
imageCount: 9,
voiceCount: 5,
stickerCount: 14,
mediaMessageCount: 28,
timeSpan: '11 h',
generatedAt: '2026-07-10 22:18',
recordNote: '基于示例数据生成的精简版日报',
footerNote: '精简版默认面向长图转发;无内容模块自动隐藏。',
heroParticipants: heroNames,
avatars,
reportMode: 'compact'
},
report: {
hero: {
headline: '接口排查和版本升级是今天主线',
summary: '白天主要围绕接口异常、升级节奏和上线安排展开,结论比争论更多,待跟进事项也比较集中。',
keyTakeaway: '大家确认本次异常更像缓存与配置问题,而不是服务端挂掉。',
pendingNote: '测试环境接口文档和回滚方案仍需补齐。',
statusLine: '今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决'
},
summaryStats: {
messageCount: 382,
activeUsers: 47,
topicCount: 4,
mediaCount: 28,
imageCount: 9,
voiceCount: 5,
stickerCount: 14,
conclusionCount: 3,
todoCount: 2,
unresolvedCount: 1
},
sectionMeta: {
hero: { enabled: true, importance: 1, confidence: 0.95, totalCount: 1, displayedCount: 1 },
topics: { enabled: true, importance: 0.98, confidence: 0.86, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
importantMessages: { enabled: true, importance: 0.95, confidence: 0.84, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
actions: { enabled: true, importance: 0.97, confidence: 0.81, totalCount: 5, displayedCount: 3, hiddenCount: 2 },
moments: { enabled: true, importance: 0.75, confidence: 0.75, totalCount: 3, displayedCount: 1, hiddenCount: 2 },
analytics: { enabled: true, importance: 0.8, confidence: 0.95, totalCount: 1, displayedCount: 1 },
keywords: { enabled: true, importance: 0.68, confidence: 0.92, totalCount: 16, displayedCount: 12, hiddenCount: 4 }
},
topics: [
{
title: 'GPT 接口异常排查',
timeRange: '09:20-11:05',
heat: '高',
summary: '上午先从接口超时和返回结构异常入手,几轮排查后,大家逐步把问题收敛到缓存与环境配置,而不是后端服务不可用。',
conclusions: [
{ text: '接口本身可用,异常更像本地缓存与环境变量冲突。' },
{ text: '先清缓存再复测,避免把旧响应误判成线上事故。' }
],
participants: ['阿宇', '老周', '小李'],
keywords: ['接口', '缓存', '环境变量'],
image: {
imageUrl: sampleImage,
note: '该图片引发 12 条回复。根据图片前后对话推断,这是一张帮助定位问题的截图。'
}
},
{
title: '版本升级节奏',
timeRange: '11:40-12:20',
heat: '中',
summary: '关于 React 版本是否立刻升级,讨论从“能不能升”转成“这周值不值得升”,最终倾向先补兼容性清单再动。',
conclusions: [{ text: '先列旧组件兼容清单,再决定升级窗口。' }],
participants: ['阿宇', 'we water', '佩佩'],
keywords: ['React', '升级', '兼容性']
},
{
title: '上线节奏与回滚准备',
timeRange: '15:10-16:05',
heat: '中',
summary: '下午讨论上线方案时,大家更关注回滚准备是否充分,最后把重点放在文档、监控和回滚路径补齐上。',
conclusions: [{ text: '上线前需要补一版简短回滚说明。' }],
participants: ['老周', '小李'],
keywords: ['上线', '回滚', '监控']
},
],
importantMessages: [
{ sender: '阿宇', time: '10:41', content: '先别回滚,接口能通。', note: '稳定了排查方向。' },
{ sender: '老周', time: '11:02', content: '像是缓存没清掉。', note: '把问题从服务端收敛到本地环境。' },
{ sender: '小李', time: '15:36', content: '上线前把回滚文档补一下。', note: '明确形成待办。' }
],
todos: [
{ task: '补测试环境接口文档', owner: '小李', topic: 'GPT 接口异常排查', note: '方便明天复测。' },
{ task: '整理回滚说明', owner: '老周', deadline: '今晚', topic: '上线节奏与回滚准备' }
],
unresolved: [
{ question: '缓存问题的根因是不是插件残留?', owner: '阿宇', status: '待跟进', lastDiscussedAt: '18:26', note: '目前只有推断,还没有最终证据。' }
],
quotes: [
{
messages: [
{ sender: '阿宇', content: '我以为接口炸了。' },
{ sender: '老周', content: '先别慌,先清缓存。' },
{ sender: '小李', content: '清完它居然真好了。' }
],
note: '从“要不要回滚”迅速切到“先做最小验证”,很像今天的群聊节奏。'
}
],
analytics: {
topSpeakers: [
{ name: '阿宇', count: 112 },
{ name: '老周', count: 78 },
{ name: '小李', count: 61 },
{ name: 'we water', count: 49 },
{ name: '佩佩', count: 33 }
],
activeTimeline: '09:00-09:59、10:00-10:59、15:00-15:59',
voiceLeaderboard: []
},
keywords: ['GPT', '接口', '缓存', '升级', '上线', '回滚', '监控', '兼容性', '截图', '文档', '测试环境', '复测'],
media: {
gallery: [],
voiceHighlights: [],
funBadges: []
},
resources: [],
qa: [],
storylines: [],
reversals: [],
participantChains: []
}
}
const fullRequest = JSON.parse(JSON.stringify(compactRequest))
fullRequest.metadata.recordNote = '基于示例数据生成的完整版日报'
fullRequest.metadata.reportMode = 'full'
fullRequest.report.sectionMeta = {
...fullRequest.report.sectionMeta,
resources: { enabled: true, importance: 0.58, confidence: 0.76, totalCount: 2, displayedCount: 2 },
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
}
fullRequest.report.resources = [
{ title: '测试环境接口文档', description: '明天复测会直接用到的说明。', sender: '小李' },
{ title: '回滚说明草稿', description: '上线前确认回滚路径与负责人。', sender: '老周' }
]
fullRequest.report.qa = [
{ question: '今晚要不要升级 React', answer: '先不升级,先补兼容清单。', answerer: 'we water' },
{ question: '接口是不是服务端挂了?', answer: '不是,当前更像缓存与环境问题。', answerer: '老周' }
]
fullRequest.report.storylines = [
{
title: '接口异常排查线',
stages: [
{ time: '09:20', event: '阿宇提出接口异常。' },
{ time: '09:46', event: '老周建议先清缓存。' },
{ time: '10:41', event: '确认接口本身可通。' }
],
result: '初步定位为缓存与配置冲突。'
},
{
title: '版本升级讨论线',
stages: [
{ time: '11:40', event: '开始讨论是否本周升级。' },
{ time: '12:05', event: '补充兼容性与工期顾虑。' }
],
result: '今晚不升,先补兼容清单。'
}
]
fullRequest.report.reversals = [
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
]
fullRequest.report.media = {
gallery: [
{
sender: '阿宇',
time: '09:52',
imageUrl: sampleImage,
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
stats: '12 条后续消息 · 6 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
},
{
sender: '佩佩',
time: '17:14',
imageUrl: sampleImage,
note: '第二张图带起一轮轻松但有效的快速确认。',
stats: '5 条后续消息 · 3 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
}
],
voiceHighlights: [
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
],
funBadges: [
{ title: '高能输出王', owner: '阿宇', note: '今天一共发了 112 条消息。' },
{ title: '语音麦霸', owner: '老周', note: '语音总时长位列第一。' }
]
}
fullRequest.report.participantChains = [
{ topic: '接口异常排查', chain: ['阿宇 提出', '老周 收敛方向', '小李 验证', 'we water 定结论'], note: '比较典型的一条技术讨论链路。' }
]
fullRequest.report.analytics.voiceLeaderboard = [
{ sender: '老周', count: 3, durationSec: 97 },
{ sender: '阿宇', count: 2, durationSec: 54 }
]
async function renderRequest(request, targetBase) {
let html = await fs.readFile(templatePath, 'utf8')
const report = request.report
const metadata = request.metadata
const topicCards = report.topics
.map((topic) => {
const conclusions = (topic.conclusions || [])
.slice(0, 2)
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
.join('')
const image = topic.image?.imageUrl
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
: ''
return `<div class="card topic-card"><div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${topic.heat === '高' ? 'hot' : topic.heat === '低' ? 'blue' : ''}">${escapeHtml(topic.heat)}热</span></div><div class="topic-meta">${escapeHtml(topic.timeRange)}</div><p>${escapeHtml(topic.summary)}</p>${conclusions ? `<div class="topic-conclusions">${conclusions}</div>` : ''}${image}<div class="participants">${topic.participants.map((name) => `<span class="person-chip"><img src="${avatars[name] || avatarSvg(name[0], '#e5e7eb')}" alt=""><b>${escapeHtml(name)}</b></span>`).join('')}</div><div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div></div>`
})
.join('')
const importantMessages = report.importantMessages
.map((message) => `<div class="important-card"><img class="avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div><div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div></div>`)
.join('')
const todoCards = (report.todos || []).map((item) => `<div class="action-card todo-card"><b>${escapeHtml(item.task)}</b><div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
const unresolvedCards = (report.unresolved || []).map((item) => `<div class="action-card unresolved-card"><b>${escapeHtml(item.question)}</b><div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div><div class="action-note">${escapeHtml(item.note)}</div></div>`).join('')
const quoteBlocks = report.quotes.map((quote) => `<div class="chat-block">${quote.messages.map((message) => `<div class="chat-msg"><img class="chat-avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div><div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div></div></div>`).join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`).join('')
const rankItems = report.analytics.topSpeakers.map((speaker, index) => `<div class="rank"><img src="${avatars[speaker.name] || avatarSvg(speaker.name[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${speaker.count} 条</span></div>`).join('')
const cloudTags = report.keywords.map((word, index) => `<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`).join('')
const resourceItems = (report.resources || []).map((resource) => `<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`).join('')
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
const chainCards = (report.participantChains || []).map((item) => `<div class="card chain-card"><div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div><div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}</div>`).join('')
const replaceMap = {
REPORT_TITLE: `${metadata.groupName}日报`,
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: metadata.groupName,
DATE_RANGE: metadata.dateRange,
RECORD_NOTE: metadata.recordNote,
REPORT_MODE_LABEL: metadata.reportMode === 'full' ? '完整版' : '精简版',
HERO_HEADLINE: report.hero.headline,
HERO_SUMMARY: report.hero.summary,
HERO_STATUS_LINE: report.hero.statusLine || '',
HERO_STATUS_EMPTY_CLASS: report.hero.statusLine ? '' : 'empty-section',
HERO_TAKEAWAY: report.hero.keyTakeaway || '',
HERO_TAKEAWAY_EMPTY_CLASS: report.hero.keyTakeaway ? '' : 'empty-section',
HERO_PENDING: report.hero.pendingNote || '',
HERO_PENDING_EMPTY_CLASS: report.hero.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(report.summaryStats.messageCount),
ACTIVE_USERS: String(report.summaryStats.activeUsers),
TOPIC_COUNT: String(report.summaryStats.topicCount),
MEDIA_COUNT: String(report.summaryStats.mediaCount),
TOPICS_EMPTY_CLASS: report.sectionMeta.topics?.enabled ? '' : 'empty-section',
TOPIC_CARDS: topicCards,
TOPICS_MORE_NOTE: report.sectionMeta.topics?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看</div>` : '',
MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
IMPORTANT_MESSAGES: importantMessages,
MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看</div>` : '',
ACTIONS_EMPTY_CLASS: report.sectionMeta.actions?.enabled ? '' : 'empty-section',
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
TODO_CARDS: todoCards,
UNRESOLVED_EMPTY_CLASS: report.unresolved.length ? '' : 'empty-section',
UNRESOLVED_CARDS: unresolvedCards,
ACTIONS_MORE_NOTE: report.sectionMeta.actions?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看</div>` : '',
QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
QUOTE_BLOCKS: quoteBlocks,
QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看</div>` : '',
ANALYTICS_EMPTY_CLASS: '',
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: report.analytics.activeTimeline,
CONCLUSION_COUNT: String(report.summaryStats.conclusionCount),
TODO_COUNT: String(report.summaryStats.todoCount),
UNRESOLVED_COUNT: String(report.summaryStats.unresolvedCount),
KEYWORDS_EMPTY_CLASS: report.sectionMeta.keywords?.enabled ? '' : 'empty-section',
CLOUD_TAGS: cloudTags,
KEYWORDS_MORE_NOTE: report.sectionMeta.keywords?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看</div>` : '',
RESOURCES_EMPTY_CLASS: report.sectionMeta.resources?.enabled ? '' : 'empty-section',
RESOURCE_ITEMS: resourceItems,
RESOURCES_MORE_NOTE: '',
QA_EMPTY_CLASS: report.sectionMeta.qa?.enabled ? '' : 'empty-section',
QA_CARDS: qaCards,
QA_MORE_NOTE: '',
STORYLINES_EMPTY_CLASS: report.sectionMeta.storylines?.enabled ? '' : 'empty-section',
STORYLINE_CARDS: storylineCards,
STORYLINES_MORE_NOTE: '',
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
REVERSAL_CARDS: reversalCards,
REVERSALS_MORE_NOTE: '',
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
GALLERY_CARDS: galleryCards,
GALLERY_MORE_NOTE: '',
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: '',
VOICE_RANK_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_RANK_CARDS: voiceRankCards,
BADGES_EMPTY_CLASS: report.sectionMeta.badges?.enabled ? '' : 'empty-section',
BADGE_CARDS: badgeCards,
BADGES_MORE_NOTE: '',
CHAINS_EMPTY_CLASS: report.sectionMeta.chains?.enabled ? '' : 'empty-section',
CHAIN_CARDS: chainCards,
CHAINS_MORE_NOTE: '',
GENERATED_AT: metadata.generatedAt,
FOOTER_NOTE: metadata.footerNote
}
for (const [key, value] of Object.entries(replaceMap)) html = replacePlaceholder(html, key, value)
const htmlPath = path.join(outputDir, `${targetBase}.html`)
const pngPath = path.join(outputDir, `${targetBase}.png`)
await fs.ensureDir(outputDir)
await fs.writeFile(htmlPath, html, 'utf8')
const win = new BrowserWindow({
show: false,
width: 430,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
})
await win.loadFile(htmlPath)
await win.webContents.executeJavaScript(`Promise.all([
document.fonts.ready,
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}))
])`)
win.webContents.debugger.attach('1.3')
const metrics = await win.webContents.debugger.sendCommand('Page.getLayoutMetrics')
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
const height = Math.ceil(metrics.cssContentSize.height)
const screenshot = await win.webContents.debugger.sendCommand('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: true,
fromSurface: true,
clip: { x: 0, y: 0, width, height, scale: 1 }
})
await fs.writeFile(pngPath, Buffer.from(screenshot.data, 'base64'))
if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach()
win.destroy()
return { htmlPath, pngPath, height }
}
app.whenReady().then(async () => {
const mode = process.argv.includes('--full') ? 'full' : 'compact'
const result =
mode === 'full'
? await renderRequest(fullRequest, 'full-fixture')
: await renderRequest(compactRequest, 'compact-fixture')
console.log(JSON.stringify({ mode, result, outputDir }, null, 2))
app.quit()
})
+153
View File
@@ -0,0 +1,153 @@
const { fork } = require('node:child_process')
const { existsSync, mkdtempSync } = require('node:fs')
const { rm } = require('node:fs/promises')
const { tmpdir } = require('node:os')
const { join } = require('node:path')
const { randomUUID, createHash } = require('node:crypto')
const workerPath = join(__dirname, '..', 'out', 'main', 'knowledgeWorker.js')
if (!existsSync(workerPath)) throw new Error(`Knowledge worker build is missing: ${workerPath}`)
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-worker-'))
const child = fork(workerPath, [], {
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
serialization: 'advanced',
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
})
const pending = new Map()
function request(type, payload) {
const requestId = randomUUID()
return new Promise((resolve, reject) => {
pending.set(requestId, { resolve, reject })
child.send({ version: 1, type, requestId, payload }, (error) => {
if (error) reject(error)
})
})
}
child.on('message', (message) => {
if (!message || message.type === 'progress') return
const current = pending.get(message.requestId)
if (!current) return
pending.delete(message.requestId)
if (message.type === 'error') current.reject(new Error(message.error))
else current.resolve(message.payload)
})
function fts(profileId) {
return {
profileId,
tokenizer: 'trigram',
contentMode: 'external',
detail: 'full',
columnsize: 1
}
}
function conversation(accountId, id) {
return {
conversationId: `conversation-${id}`,
completeSnapshot: true,
messages: [
{
accountId,
conversationId: `conversation-${id}`,
messageId: `message-${id}`,
createTime: 1,
senderId: 'fixture-member',
senderName: '脱敏成员',
kind: 'text',
text: `脱敏索引内容 ${id}`
}
]
}
}
function accountPath(accountId) {
const key = createHash('sha256')
.update(`knowledge-account-v1:${accountId}`)
.digest('hex')
.slice(0, 32)
return join(root, key, 'knowledge.sqlite')
}
async function main() {
try {
const chunker = {
version: 'conversation-v1',
maxGapMs: 600000,
maxMessages: 12,
maxCharacters: 1200,
overlapMessages: 3
}
const accountA = 'worker-fixture-a'
const accountB = 'worker-fixture-b'
const first = await request('index', {
accountId: accountA,
databaseRoot: root,
conversations: [conversation(accountA, 'a')],
chunker,
fts: fts('worker-a')
})
await request('index', {
accountId: accountB,
databaseRoot: root,
conversations: [conversation(accountB, 'b')],
chunker,
fts: fts('worker-b')
})
if (
!first ||
first.cancelled ||
!existsSync(accountPath(accountA)) ||
!existsSync(accountPath(accountB))
) {
throw new Error('Knowledge worker did not create isolated derived databases')
}
const search = await request('search', {
accountId: accountA,
databaseRoot: root,
fts: fts('worker-a'),
text: '查询脱敏索引内容 a',
terms: ['脱敏索引内容', 'a'],
limit: 10
})
const evidence = search?.evidence?.[0]
if (
search?.state !== 'ready' ||
!evidence ||
evidence.messageId !== 'message-a' ||
evidence.conversationId !== 'conversation-a' ||
evidence.sender !== '脱敏成员' ||
typeof evidence.timestamp !== 'number'
) {
throw new Error('Knowledge worker search did not return message-level evidence')
}
await request('remove', { accountId: accountA, databaseRoot: root })
if (existsSync(accountPath(accountA)) || !existsSync(accountPath(accountB))) {
throw new Error('Knowledge worker removal crossed an account boundary')
}
const unavailable = await request('search', {
accountId: accountA,
databaseRoot: root,
fts: fts('worker-a'),
text: '查询脱敏索引内容 a',
terms: ['脱敏索引内容'],
limit: 10
})
if (unavailable?.state !== 'unavailable' || unavailable.evidence?.length) {
throw new Error('Knowledge worker did not report unavailable index after removal')
}
await request('close', {})
console.log('Knowledge worker integration check passed')
} finally {
child.kill()
await rm(root, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
@@ -0,0 +1,70 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const filePath = path.join(
__dirname,
'..',
'src',
'renderer',
'src',
'features',
'api-center',
'utils',
'buildSkillInstallInstruction.ts'
)
const source = fs.readFileSync(filePath, 'utf8')
const output = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS }
}).outputText
const moduleExports = {}
new Function('exports', 'require', 'module', output)(moduleExports, require, {
exports: moduleExports
})
const { buildSkillInstallInstruction } = moduleExports
const local = {
type: 'local',
directoryPath: 'C:/skill/tracememo-reader',
skillPath: 'C:/skill/tracememo-reader/SKILL.md',
version: 'v1.0'
}
for (const [target, expected] of [
['codex', 'Codex 项目或用户 Skill 目录'],
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
['openclaw', '作为 TraceMemo Reader Skill 安装'],
['generic', '读取并安装']
]) {
const text = buildSkillInstallInstruction({
target,
source: local,
apiBaseUrl: { host: '127.0.0.1', port: 6131 }
})
assert.match(text, new RegExp(expected))
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
assert.match(text, /TRACEMEMO_API_TOKEN/)
assert.match(text, /WECHATEXPLORER_API_TOKEN/)
assert.match(text, /Authorization: Bearer/)
assert.doesNotMatch(text, /mcpServers/)
}
assert.match(
buildSkillInstallInstruction({
target: 'codex',
source: local,
apiBaseUrl: { host: '0.0.0.0', port: 7000 }
}),
/http:\/\/127\.0\.0\.1:7000\/api\/v1\/health/
)
assert.match(
buildSkillInstallInstruction({
target: 'generic',
source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' },
apiBaseUrl: { host: 'localhost', port: 6131 }
}),
/https:\/\/example\.com\/skill/
)
console.log('skill install instruction tests passed')
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fastclaw-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
# TraceMemo WeChat Connector
This repository-local service provides the minimal WeChat bridge required by TraceMemo:
- QR-code login with a single persisted credential
- account discovery
- inbound long polling and authenticated webhook delivery
- local HTTP health and send endpoints
- text and local/remote media sending
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
## Commands
```bash
go run . login --json
go run . accounts --json
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
```
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. This legacy directory name is intentionally retained so upgrades can reuse existing accounts. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
## Attribution
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). TraceMemo-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding TraceMemo project.
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
)
// Server provides an HTTP API for sending messages.
type Server struct {
clients []*ilink.Client
addr string
}
// NewServer creates an API server.
func NewServer(clients []*ilink.Client, addr string) *Server {
if addr == "" {
addr = "127.0.0.1:18011"
}
return &Server{clients: clients, addr: addr}
}
// SendRequest is the JSON body for POST /api/send.
type SendRequest struct {
AccountID string `json:"account_id,omitempty"`
To string `json:"to"`
Text string `json:"text,omitempty"`
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
}
// Run starts the HTTP server. Blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
log.Printf("[api] listening on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" {
http.Error(w, `"to" is required`, http.StatusBadRequest)
return
}
if req.Text == "" && req.MediaURL == "" {
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
return
}
if len(s.clients) == 0 {
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
return
}
client := s.clientForAccount(req.AccountID)
if client == nil {
http.Error(w, "requested account is not available", http.StatusNotFound)
return
}
ctx := r.Context()
// Send text if provided
if req.Text != "" {
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
log.Printf("[api] send text failed: %v", err)
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
// Extract and send any markdown images embedded in text
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
log.Printf("[api] send extracted image failed: %v", err)
} else {
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
}
}
}
// Send media if provided
if req.MediaURL != "" {
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
log.Printf("[api] send media failed: %v", err)
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) clientForAccount(accountID string) *ilink.Client {
if accountID == "" {
return s.clients[0]
}
for _, client := range s.clients {
if client.BotID() == accountID {
return client
}
}
return nil
}
@@ -0,0 +1,20 @@
package api
import (
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
if got := server.clientForAccount("bot-new"); got != newClient {
t.Fatal("clientForAccount did not select the requested account")
}
if got := server.clientForAccount("missing"); got != nil {
t.Fatal("clientForAccount should reject an unknown account")
}
}

Some files were not shown because too many files have changed in this diff Show More