Compare commits

...
Author SHA1 Message Date
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
145 changed files with 5762 additions and 1379 deletions
+1
View File
@@ -1 +1,2 @@
shamefully-hoist=true
electron_mirror=https://npmmirror.com/mirrors/electron/
+78 -23
View File
@@ -1,7 +1,7 @@
# WechatExplorer
# TraceMemo(迹忆)
<p align="center">
<img src="./build/icon.png" width="120" alt="WechatExplorer Logo" />
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
</p>
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
@@ -18,7 +18,7 @@
</p>
<p align="center">
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 WechatExplorer</b></a>
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
·
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
·
@@ -26,16 +26,30 @@
</p>
<p align="center">
<img src="./public/software-1.png" alt="WechatExplorer 主界面" />
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
</p>
<p align="center">
<img src="./public/机器人.png" alt="WechatExplorer 主界面" />
<img src="./public/机器人.png" alt="TraceMemo 主界面" />
</p>
## WechatExplorer 是什么
## TraceMemo(迹忆)是什么
WechatExplorer 是一本地优先的微信聊天记录搜索与 AI 工作台。
TraceMemo(迹忆)是一本地优先、可追溯的 AI 微信知识与分析工作台。
TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
## 为什么叫 TraceMemo(迹忆)
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
“迹忆”可以理解为“留下痕迹的记忆”。TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
> **品牌说明**
>
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
@@ -47,6 +61,12 @@ WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
## 💬 交流与反馈
<p align="center">
<img src="./public/二维码.jpg" alt="TraceMemo 交流与售后群二维码" width="280" />
</p>
## 从你的任务开始
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
@@ -58,7 +78,7 @@ WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
| 直接在微信里向 WechatExplorer 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
## 最核心的三个能力
@@ -76,7 +96,7 @@ WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
WechatExplorer 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
<p align="center">
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
@@ -86,11 +106,11 @@ WechatExplorer 会先在本机查找候选消息,再把整理后的少量来
### 直接在微信里问你的历史聊天
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。例如,你可以直接给机器人发送“最近 5 个会话”“张三最近和我聊了什么”,或者让它生成指定群聊的总结图片。WechatExplorer 会在本机读取已连接的聊天数据并把结果回复到微信。
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。例如,你可以直接给机器人发送“最近 5 个会话”“张三最近和我聊了什么”,或者让它生成指定群聊的总结图片。TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
详细步骤和能力边界见[在微信里向 WechatExplorer 提问](./docs/agent/agent-hub.md)。
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
## 其他能力
@@ -119,13 +139,13 @@ WechatExplorer 会先在本机查找候选消息,再把整理后的少量来
### 转写微信语音
WechatExplorer 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
详细说明:[语音转文字](./docs/user-guide/voice.md)
### 防撤回
可选开启后,WechatExplorer 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
可选开启后,TraceMemo 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
详细说明:[防撤回](./docs/user-guide/recall-protection.md)
@@ -145,7 +165,7 @@ WechatExplorer 支持在本机转写单条或批量微信语音,结果可以
```mermaid
flowchart LR
A[本机微信数据] --> B[WechatExplorer 读取与解析]
A[本机微信数据] --> B[TraceMemo 读取与解析]
B --> C[聊天档案]
B --> D[本地知识库与搜索]
D --> E[筛选相关聊天来源]
@@ -170,26 +190,30 @@ flowchart LR
## 支持平台与安装包
| 平台 | 处理器架构 | Releases 安装包 |
| ------- | ------------------------------------ | --------------- |
| Windows | x64 | `-setup.exe` |
| macOS | Intelx64)、Apple Siliconarm64 | `.dmg` |
| 平台 | 处理器架构 | Releases 安装包 |
| ------- | ------------------------------ | --------------- |
| Windows | x64 | `-setup.exe` |
| macOS | Apple SiliconM 系列、arm64 | `.dmg` |
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
## 快速开始
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
2. 启动 WechatExplorer,按照“第一次使用”页面选择微信数据目录。
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)。
完整步骤:[第一次使用 WechatExplorer](./docs/user-guide/getting-started.md)
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
## 配置 AI
@@ -239,10 +263,41 @@ pnpm test:e2e:build
## 支持与反馈
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。提交 Issue 时请提供操作系统、微信版本、WechatExplorer 版本、复现步骤和已遮挡敏感信息的截图。
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。提交 Issue 时请提供操作系统、微信版本、TraceMemo 版本、复现步骤和已遮挡敏感信息的截图。
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
## 许可说明
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `LICENSE` 文件;贡献、复制或再分发前,请先向维护者确认 WechatExplorer 本身的许可范围。
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `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>
-241
View File
@@ -1,241 +0,0 @@
# WechatExplorer 产品经理交接
> 面向下一任产品经理的产品事实、边界和文档维护说明。
>
> - 最后核验:2026-08-07
> - 仓库版本:`2.1.9`
> - 基准提交:`cd2c3cf``docs: 更新文档`
## 1. 接手时先记住什么
WechatExplorer 当前可以定位为:
> **一个本地优先的微信聊天记录查看、搜索、整理与 AI 分析工具。**
它把几类原本分散的任务放在一起:
- 浏览和搜索本机微信聊天;
- 用 AI 查找历史信息,并回到来源消息核对;
- 建立本地知识库,辅助跨会话、跨时间查找;
- 生成群聊日报,转写语音,导出聊天档案;
- 让外部 Agent 或应用内微信机器人按边界使用本机数据能力。
产品表达应先回答用户能完成什么,再解释 Knowledge、Evidence、Citation、Search Trace、FTS 等内部术语。README 负责定位、主要价值和最短上手路径;完整步骤、限制和安全说明放在 `docs/`
### 事实来源优先级
描述“当前支持”前,按以下顺序核验:
1. 当前源码和 UI
2. 当前测试;
3. `package.json`、构建和发布配置;
4. 正式 `docs/`
5. README
6. 历史说明和产品设想。
历史文档、旧版本描述和聊天记录不能单独证明当前能力。新增事实陈述时,最好同时写清限制并链接到正式文档或实现位置。
## 2. 当前已验证的产品能力
本节是能力地图,不替代正式使用手册。用户步骤和异常处理以链接的正式文档为准。
### 2.1 连接与查看微信数据
当前代码面向微信 4.x 数据结构,支持在 macOS 和 Windows 上连接本机微信数据。连接结果会受到微信版本、账号数据、系统权限和数据迁移状态影响。
连接成功后,“档案”可以浏览已读取到的联系人、群聊、折叠群聊和公众号会话,并显示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。
边界:消息类型可被读取,不等于对应媒体一定可以解码或显示。来源文件缺失、权限不足或微信存储方式变化都可能导致媒体不可用。
来源:[第一次使用](./docs/user-guide/getting-started.md)、[查看和搜索聊天](./docs/user-guide/chat-archive.md)
### 2.2 普通搜索与 AI Search
档案内关键词搜索适合已知原话、文件名、人名或大致会话的任务。AI Search 适合“记得含义但不记得关键词或位置”的问题。
AI Search 会先在本机查找候选聊天,再把完成任务所需的受控上下文交给用户配置的 AI Provider。回答可以展示来源会话、发送者、时间、来源标记和检索过程,并允许用户回到档案检查上下文。
边界:Evidence、Citation 和 Search Trace 提供核对路径,不保证模型结论正确,也不保证选定范围之外没有遗漏。关键决定仍应回到原始消息确认。
来源:[AI Search](./docs/user-guide/ai-search.md)、[如何核对 AI 回答来源](./docs/concepts/answer-sources.md)
### 2.3 本地知识库
用户可以主动为当前微信账号建立本地索引,并在之后同步新增或变化的记录。不同微信账号使用独立索引;界面会显示索引规模和磁盘占用。
清理知识库不会删除微信原始数据库。知识库同步期间新的 AI 分析会暂停,但已有索引在部分异常情况下仍可能可用。
边界:知识库是本地检索资料,不是新的微信数据库,也不是所有问题都必须先建立。只查一条已知原话时,普通搜索通常更直接。
来源:[本地知识库](./docs/user-guide/knowledge.md)、`src/main/knowledge/``tests/unit/knowledge-*.test.ts`
### 2.4 群聊日报
用户可以选择一个群聊以及今天、昨天或近 7 天的范围,生成可能包含主题、重要消息、问答、资源、待办、未解决事项、关键词、活跃统计和可用媒体精选的报告。成功结果会保存为本地 HTML 和 PNG。
边界:当前日报入口只支持群聊。具体栏目取决于所选消息、媒体可用性和模型能力;图片不可读或模型未通过图片理解验证时,图片精选会跳过。语音数量统计不代表语音内容已经转写或理解。
来源:[群聊日报](./docs/user-guide/report.md)、`src/renderer/src/utils/group-report-facts.ts`
### 2.5 本地语音转写
应用使用本地 SenseVoice/sherpa-onnx 运行时转写微信语音,支持单条转写和按联系人或群聊批量处理。批量任务支持进度、取消、缓存复用和部分失败提示。
成功转写的文本可以用于查看、本地知识库检索和导出。离线转写本身在本机完成;如果用户随后把转写文本用于 AI Search 或日报,文本会按对应 AI 功能的规则处理。
边界:首次使用可能需要下载模型。失败或尚未转写的语音不会被自动当作已理解内容。
来源:[语音转文字](./docs/user-guide/voice.md)、`src/main/voice-pipeline/``tests/unit/voice-*.test.ts`
### 2.6 聊天导出
当前支持 HTML、Markdown、CSV 和 JSON
| 能力 | 当前边界 |
| ------------------- | -------------------------------------------------- |
| HTML | 可包含媒体和头像;支持最多五个会话合并 |
| Markdown、CSV、JSON | 主要保留文本内容,不包含 HTML 资源文件 |
| ZIP | 是 HTML 资源包的压缩选项,不是独立内容格式 |
| 增量合并 | 仅适用于同名 HTML 档案 |
| 语音转写 | 只写入已经成功取得的转写文本,不会自动补齐失败内容 |
导出不会修改微信原始数据库;删除导出文件也不会删除应用中的聊天或知识库。
来源:[导出聊天](./docs/user-guide/export.md)、`src/main/export-service.ts``src/renderer/src/components/export/ExportWorkspace.tsx``tests/integration/export-media-flow.test.ts`
### 2.7 外部 Agent 与 Local HTTP API
外部 Agent 可以安装随应用提供的 Reader Skill,通过本机 Local HTTP API 按需读取联系人、会话、指定时间范围的聊天和群成员信息,也可以请求生成群聊总结图片。
当前 API 默认地址为 `http://127.0.0.1:6131`
- `GET /api/v1/health` 不需要 Token
- 其他端点需要 `Authorization: Bearer <TOKEN>`
- Token 在 API Center 中显示、复制和重新生成;
- 重新生成后旧 Token 立即失效;
- API 没有细粒度用户 Scope,不应转发到公网。
`6131` 是普通 Local HTTP API,当前不是 MCP Server。Reader Skill 不会自动监听微信实时消息。
来源:[Agent 接入概览](./docs/agent/overview.md)、[Local HTTP API](./docs/agent/api.md)、[API 安全](./docs/agent/api-security.md)、[Reader Skill](./docs/agent/reader-skill.md)
### 2.8 Agent Hub 与微信机器人
Agent Hub 是应用内的实时微信入口。用户扫码连接一个微信机器人账号后,机器人收到文字消息,Agent Hub 可以查询本机数据、按需调用已配置的 AI,并把结果回复给触发请求的微信用户。
当前明确支持的实时任务包括:
- 查看最近会话,数量限制为 1 至 20;
- 查询与某位联系人的近期聊天;
- 总结与某位联系人近 7 天的聊天;
- 生成今天、昨天或近 7 天的群聊总结图片;
- 总结指定群成员的近期发言;
- 对不需要读取聊天的普通文字请求给出有限的 AI 回复。
边界:实时自然语言入口主要处理文字。底层连接器可以接收其他媒体,但 Agent Hub 尚未提供同等的图片、语音、文件和视频意图处理。它也没有群发、广播、定时任务或通用自主操作微信的能力。
来源:[Agent Hub](./docs/agent/agent-hub.md)、`src/main/agent/`、Agent Hub 相关测试
## 3. 隐私与安全边界
“本地优先”不能表达成“所有数据永远不会离开电脑”。
默认在本机完成的处理包括:微信数据库读取和解析、档案浏览、普通关键词搜索、本地知识库索引、离线语音转写、导出文件生成和本地日报历史。
当用户主动使用 AI Search、群聊日报或图片理解,并配置远程 Provider 时,用户问题、受控检索上下文和最终用于总结的来源内容可能发送给该 Provider。Provider 的日志、保留、计费和地区规则不由 WechatExplorer 控制。
外部 Agent 是否把 API 读取结果继续发送给云端模型,取决于 Agent 自己的配置。Agent Hub 的机器人账号、个人微信数据库连接和外部 Agent/API Token 是三条不同的安全边界。
来源:[数据、隐私与安全](./docs/user-guide/privacy.md)
## 4. 尚未实现或不能宣称的能力
以下内容不是当前能力。未来讨论这些方向时,必须明确写成“未来场景 / 尚未实现”,且不能据此承诺路线图或发布时间:
- **未来场景 / 尚未实现:**按固定时间自动生成或发送每日群聊总结;
- **未来场景 / 尚未实现:**群发、广播或通用微信自动化;
- **未来场景 / 尚未实现:**Agent Hub 对图片、语音、文件和视频提供与文字相同的实时理解能力;
- **未来场景 / 尚未实现:**将 `127.0.0.1:6131` 作为 MCP Server 使用;
- **未来场景 / 尚未实现:**对外提供实时入站 webhook 或由 Reader Skill 订阅实时微信消息;
- **不能宣称:**所有微信 4.x 版本、所有账号和所有系统组合都能稳定连接;
- **不能宣称:**所有图片、视频、文件或语音都一定能读取、解码或理解;
- **不能宣称:**AI 回答或日报一定完整、准确,或者 Evidence 本身能保证结论正确;
- **不能宣称:**启用远程 AI 后所有数据仍只停留在本机。
## 5. 仅凭当前仓库仍无法确认的事项
以下问题需要真实发布环境、用户研究或外部平台信息,不能仅凭当前源码和测试得出结论:
1. GitHub Releases 中各平台安装包当前是否齐全、可下载,以及在不同系统安全策略下的实际安装成功率;
2. 不同微信 4.x 小版本、历史迁移状态和真实账号规模下的连接成功率与兼容矩阵;
3. 超大聊天历史下,索引、AI Search、日报、语音批处理和导出的真实耗时、容量上限与失败率;
4. 微信机器人账号在长期运行中的登录稳定性、平台规则风险和账号限制;
5. 用户是否真正理解并使用“来源核对”、Knowledge、Reader Skill 和 Agent Hub,以及这些功能是否改善了实际任务结果。
这些事项在得到真实证据前,应写成“待验证”,不能转写成产品优势。
## 6. 文档职责和维护方法
### README
README 只负责:
- 一句话说明产品是什么;
- 展示最重要的用户任务和差异;
- 给出最短上手路径;
- 引导到正式 docs。
不要把完整 API、安全实现、数据库结构、检索原理或所有边缘情况塞进 README。
### 正式 docs
- `docs/user-guide/`:第一次使用、档案、AI Search、Knowledge、日报、语音、导出、隐私和排障;
- `docs/concepts/`:来源核对和工作原理;
- `docs/agent/`Reader Skill、Local HTTP API、安全和 Agent Hub
- `docs/platform/`:平台权限和限制;
- `docs/development/`:开发、测试、构建及代码与文档的对应关系。
文档入口见:[docs 首页](./docs/README.md)。
### 每次产品变更后的检查
1. 用户是否能直接感知变化?如果能,检查 README 和对应 User Guide
2. 第一次使用路径是否变化?如果变化,检查 Getting Started
3. AI 的输入、来源或完整性提示是否变化?如果变化,检查 AI Search、Answer Sources 和 Privacy
4. API、Token、Reader Skill 或 Agent Hub 是否变化?如果变化,同步检查全部 Agent 文档;
5. 兼容性、构建或发布范围是否变化?如果变化,检查平台和开发文档;
6. 文案是否把“可能”“计划”“测试样例”误写成“当前支持”?
推荐的能力陈述格式是:
> 用户可以完成什么 + 当前限制是什么 + 事实来源在哪里。
## 7. 下一任产品经理最应该关注的 5 个产品问题
### 1. 新用户能否在几分钟内完成第一次连接
连接微信数据是所有能力的前置条件。需要建立真实平台和微信版本的成功率、失败原因和耗时数据,而不只依赖开发环境与测试样例。
### 2. 用户能否理解 AI 回答的可信边界
“可回到来源核对”是重要差异,但目前仍需要验证用户是否会打开来源、是否看得懂覆盖提示,以及这些信息能否减少错误决策。
### 3. Knowledge 是否解决了用户可感知的问题
需要验证建立和同步索引的成本、等待时间与搜索收益是否匹配,并明确哪些任务适合普通搜索、哪些任务真正需要 Knowledge。
### 4. Reader Skill 和 Agent Hub 的定位是否足够清楚且安全
两条路径服务不同用户,也有不同的 Token、机器人账号和数据外发边界。需要验证入口命名、配置流程、权限提示和失败恢复是否让用户理解。
### 5. 兼容性和分发是否足以支撑产品承诺
需要维护真实的系统、处理器、微信版本和账号数据兼容矩阵,同时确认安装包、系统授权、连接器和升级路径在发布环境中可用。
## 8. 接手原则
- 先核实能力,再决定怎么宣传;
- 用用户任务描述价值,用正式 docs 承担细节;
- 明确区分当前能力、限制、待验证事项和未来场景;
- 不因文案完整性补充不存在的能力;
- 不把测试通过等同于真实用户环境已经得到验证。
+7 -5
View File
@@ -1,6 +1,6 @@
# WechatExplorer 文档
# TraceMemo 文档
WechatExplorer 的文档按“你想完成什么”组织,而不是按源码模块组织。
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
## 从这里开始
@@ -15,7 +15,7 @@ WechatExplorer 的文档按“你想完成什么”组织,而不是按源码
- [语音转文字](./user-guide/voice.md)
- [导出聊天档案](./user-guide/export.md)
- [防撤回](./user-guide/recall-protection.md)
- [在微信里向 WechatExplorer 提问](./agent/agent-hub.md)
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
- [数据、隐私与安全](./user-guide/privacy.md)
- [常见问题与排查](./user-guide/troubleshooting.md)
@@ -26,7 +26,7 @@ WechatExplorer 的文档按“你想完成什么”组织,而不是按源码
## 微信机器人和外部 Agent
WechatExplorer 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
| 你想做什么 | 应该看哪里 |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
@@ -55,6 +55,8 @@ WechatExplorer 有两种不同的接入方式。微信机器人是普通用户
- [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.1.9**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
+5 -5
View File
@@ -1,6 +1,6 @@
# 在微信里向 WechatExplorer 提问(Agent Hub
# 在微信里向 TraceMemo 提问(Agent Hub
Agent Hub 是 WechatExplorer 内置的微信机器人入口,也是应用一级导航中的“Agent”页面。你先扫码登录一个微信机器人账号,再用微信账号向机器人发送文字;本机 Agent Hub 会接收消息、读取已经连接的微信数据,必要时调用已配置的 AI,再把结果回复给发送者。
Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导航中的“Agent”页面。你先扫码登录一个微信机器人账号,再用微信账号向机器人发送文字;本机 Agent Hub 会接收消息、读取已经连接的微信数据,必要时调用已配置的 AI,再把结果回复给发送者。
普通用户不需要安装 Reader Skill,也不需要配置 API Token。先连接微信数据库,再扫码登录机器人即可开始;需要总结或自然语言理解的任务还要配置 AI Provider。
@@ -11,7 +11,7 @@ Agent Hub 是 WechatExplorer 内置的微信机器人入口,也是应用一级
## 连接器和 Agent Hub 是什么关系
你不需要单独部署这些组件。扫码后,后台的微信连接器负责登录机器人、保持连接、接收微信消息和发送回复;Agent Hub 负责判断消息要做什么、查询 WechatExplorer 本地数据、调用 AI 并组织结果。可以把它理解为:连接器负责“和微信通信”,Hub 负责“处理任务”。
你不需要单独部署这些组件。扫码后,后台的微信连接器负责登录机器人、保持连接、接收微信消息和发送回复;Agent Hub 负责判断消息要做什么、查询 TraceMemo 本地数据、调用 AI 并组织结果。可以把它理解为:连接器负责“和微信通信”,Hub 负责“处理任务”。
## 你能做什么
@@ -50,9 +50,9 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
## 需要满足的条件
- WechatExplorer 的微信数据库已经连接,并且数据 API 可以查询;
- TraceMemo 的微信数据库已经连接,并且数据 API 可以查询;
- 依赖总结或自然语言理解的任务,需要在“设置 → AI 模型”配置可用的 AI 服务;
- WechatExplorer 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
- TraceMemo 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
## 安全与边界
+4 -2
View File
@@ -2,10 +2,12 @@
## 当前安全边界
WechatExplorer 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
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 个随机字节编码;
@@ -17,7 +19,7 @@ WechatExplorer 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
```bash
export WECHATEXPLORER_API_TOKEN="<TOKEN>"
export TRACEMEMO_API_TOKEN="<TOKEN>"
```
## CORS 与 Origin
+21 -19
View File
@@ -1,4 +1,4 @@
# WechatExplorer Local HTTP API
# TraceMemo Local HTTP API
本文面向需要自己写集成的开发者。普通用户请先阅读[Agent 接入概览](./overview.md)。
@@ -17,29 +17,31 @@
curl http://127.0.0.1:6131/api/v1/health
# 读取数据
export WECHATEXPLORER_API_TOKEN="<从 API Center 复制的 Token>"
curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
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": "..." }` |
| 方法 | 路径 | 作用 | 参数/请求体 |
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| 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": "..." }` |
### 这些端点与实时机器人有什么关系
@@ -57,7 +59,7 @@ curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
- `YYYY-MM-DD/HH:mm`:从该分钟开始的 60 秒;
- 也可以使用 Unix 秒级 `startTime``endTime`
时间按运行 WechatExplorer 的本机时区解析。用户说“今天”“昨天”时,先调用 `current_time`,再根据返回的 `localDate` 计算日期,避免使用 Agent 自己的时区。
时间按运行 TraceMemo 的本机时区解析。用户说“今天”“昨天”时,先调用 `current_time`,再根据返回的 `localDate` 计算日期,避免使用 Agent 自己的时区。
## 常用工作流
@@ -65,7 +67,7 @@ curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
```bash
BASE="http://127.0.0.1:6131/api/v1"
AUTH="Authorization: Bearer $WECHATEXPLORER_API_TOKEN"
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"
+6 -6
View File
@@ -1,6 +1,6 @@
# 在微信机器人或外部 Agent 中使用 WechatExplorer
# 在微信机器人或外部 Agent 中使用 TraceMemo
WechatExplorer 提供两条不同路径。先按你实际想做的事选择,不需要先理解 Agent、Skill 或 API 等术语。
TraceMemo 提供两条不同路径。先按你实际想做的事选择,不需要先理解 Agent、Skill 或 API 等术语。
| 你想做什么 | 使用方式 | 需要什么 |
| ---------------------------------------------------- | ----------------------------- | --------------------------------------------------------- |
@@ -9,7 +9,7 @@ WechatExplorer 提供两条不同路径。先按你实际想做的事选择,
## 直接在微信里提问
打开应用一级导航中的“Agent”,进入“Agent Hub”,扫码登录一个微信机器人账号。之后用另一个微信账号向机器人发送文字,它会调用 WechatExplorer 的本机数据,必要时使用已配置的 AI,再把结果回复给发送者。
打开应用一级导航中的“Agent”,进入“Agent Hub”,扫码登录一个微信机器人账号。之后用另一个微信账号向机器人发送文字,它会调用 TraceMemo 的本机数据,必要时使用已配置的 AI,再把结果回复给发送者。
可以先尝试:
@@ -23,7 +23,7 @@ WechatExplorer 提供两条不同路径。先按你实际想做的事选择,
## 在外部 Agent 中查询历史微信
Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以通过 WechatExplorer Local HTTP API 按需读取联系人、群聊、最近会话、指定时间范围的聊天和群成员信息。
Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以通过 TraceMemo Local HTTP API 按需读取联系人、群聊、最近会话、指定时间范围的聊天和群成员信息。
典型问题包括:
@@ -35,11 +35,11 @@ Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Cod
## 外部 Agent 的安装步骤
1. 启动 WechatExplorer 并完成微信数据库连接。
1. 启动 TraceMemo 并完成微信数据库连接。
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
3. 选择目标 Agent,点击“复制安装指令”。
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `WECHATEXPLORER_API_TOKEN`
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)。
+8 -6
View File
@@ -2,27 +2,29 @@
## 先理解它能做什么
Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以按需调用 WechatExplorer,读取联系人、群聊、最近会话、指定时间的聊天和群成员信息。
Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以按需调用 TraceMemo,读取联系人、群聊、最近会话、指定时间的聊天和群成员信息。
它使用的是 WechatExplorer Local HTTP API,不是 MCP Server。
它使用的是 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. 启动 WechatExplorer 并完成数据库连接。
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 WECHATEXPLORER_API_TOKEN="<YOUR_API_TOKEN>"
export TRACEMEMO_API_TOKEN="<YOUR_API_TOKEN>"
```
6. 先执行 health 检查,再读取数据端点。
WechatExplorer 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
## Agent 的读取顺序
@@ -39,7 +41,7 @@ WechatExplorer 不会自动把 Token 写进 Agent 配置。重新生成 Token
```bash
curl http://127.0.0.1:6131/api/v1/health
curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
```
+2 -2
View File
@@ -1,4 +1,4 @@
# WechatExplorer 2.1.9Local HTTP API 鉴权迁移
# TraceMemo 2.1.9Local HTTP API 鉴权迁移
2.1.9 为 Local HTTP API 增加 Bearer Token 鉴权。这是一次有意的兼容性变化:除健康检查外,数据接口不再接受裸请求。
@@ -6,7 +6,7 @@
- 2.1.9 中,相同请求必须携带 `Authorization: Bearer <TOKEN>`,否则返回 `401`
- `GET /api/v1/health` 保持公开;
- 升级后应用会生成并安全保存 Token,原有 API 启用状态、监听地址和端口设置保持不变;
- Token 可在 WechatExplorer → API Center 中显示、复制和重新生成;
- 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)。
+1 -1
View File
@@ -4,7 +4,7 @@
AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内容来自哪个会话和时间,并跳回原始消息检查上下文。
这让 WechatExplorer 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
这让 TraceMemo 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
## 三类来源信息
+1 -1
View File
@@ -1,4 +1,4 @@
# WechatExplorer 如何把聊天变成可用的信息
# TraceMemo 如何把聊天变成可用的信息
你可以把一次任务想成下面这条路径:
@@ -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、微信数据路径或聊天内容。
+3 -1
View File
@@ -1,6 +1,6 @@
# 开发、测试与构建
本文面向希望参与 WechatExplorer 开发、验证文档或维护集成的贡献者。普通用户请从[第一次使用](../user-guide/getting-started.md)开始。
本文面向希望参与 TraceMemo 开发、验证文档或维护集成的贡献者。普通用户请从[第一次使用](../user-guide/getting-started.md)开始。
## 技术基线
@@ -18,6 +18,8 @@ pnpm install
pnpm dev
```
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
常用检查:
```bash
+3 -3
View File
@@ -2,11 +2,11 @@
## 你什么时候会看到这些提示
WechatExplorer 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
## 推荐步骤
1. 先启动 WechatExplorer,阅读连接页面显示的当前前置条件。
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
2. 确认微信数据目录指向当前账号。
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
4. 返回应用重新检测账号、数据库和图片资源状态。
@@ -15,7 +15,7 @@ WechatExplorer 需要读取微信本地数据。macOS 会根据系统版本、
## SIP 风险
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 WechatExplorer 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 TraceMemo 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
## 应用无法打开
+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`:用户还没有完成数据库连接或对应服务未就绪。
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
-64
View File
@@ -1,64 +0,0 @@
---
name: wechatexplorer-reader
description: 通过 WechatExplorer 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 WechatExplorer 提供数据,不是 MCP Server。
---
# WechatExplorer Reader
你是一个通过本机 WechatExplorer 读取微信历史的 Agent。先确认用户已经在 WechatExplorer 中完成数据库连接,再按需调用 API;不要假设数据库已就绪,也不要声称读取了没有调用过的消息。
## 连接信息
- Base URL 默认是 `http://127.0.0.1:6131/api/v1`
- `GET /health` 不需要 Token。
- 其他端点必须带 `Authorization: Bearer $WECHATEXPLORER_API_TOKEN`
- Token 由用户在 WechatExplorer → 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`。时间按 WechatExplorer 所在机器的本机时区解释。
当用户问“某个话题是谁说的、后来结论是什么”时,先定位会话和时间,再读取关键消息前后文。回答时区分:
- 原消息明确写出的内容;
- 根据多条消息整理出的总结;
- 没有来源支持的推断。
## 隐私和安全
只读取用户请求所需的会话和时间范围。不要把完整聊天数据库、密钥或 Token 暴露给用户。Reader API 本身不自动把聊天转发到外部服务器,但当前 Agent 可能会把工具结果交给其配置的模型;如有疑问,提醒用户检查 Agent 的数据策略。
## 常见错误
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
- `404`:先用 `/resolve` 确认会话标识。
- `503`:用户还没有完成数据库连接或对应服务未就绪。
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
+46 -16
View File
@@ -1,6 +1,6 @@
# 第一次使用 WechatExplorer
# 第一次使用 TraceMemo
如果你刚下载 WechatExplorer,只需要完成一条主线:
如果你刚下载 TraceMemo,只需要完成一条主线:
> 安装应用 → 连接微信数据 → 确认聊天已加载 → 搜索或提问。
@@ -8,29 +8,59 @@
## 1. 开始前准备
- 一台 macOS 或 Windows 电脑。
- 已安装并使用过微信桌面客户端。
- 你有权访问要读取的微信账号和聊天数据。
| 系统 | 已测试的微信客户端 | 需要注意 |
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| 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. 安装并启动
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载对应系统的安装包
2. Windows 使用 `-setup.exe` 安装;macOS 打开 `.dmg` 并将应用拖入“应用程序”。
3. 启动 WechatExplorer,进入“第一次使用”页面。
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
macOS 如果提示无法验证开发者,请按系统提示允许打开。自动获取数据库密钥需要额外系统授权时,先阅读[macOS 数据访问说明](../platform/macos.md)。不要在不理解风险的情况下长期关闭系统安全保护。
### 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. 让应用读取微信数据
第一次打开时,应用会引导你完成数据库连接。核心是两件事:找到当前微信数据目录,并取得对应账号的数据库密钥。
首次启动会自动进入“第一次使用”页面。页面会根据当前系统显示连接方式和注意事项:
1. 在连接页面确认微信数据目录。自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径后粘贴到页面。
2. 第一次使用先点击“开始连接”,按页面提示准备连接组件和获取密钥;只有已经通过其他方式拿到密钥的高级用户才选择“手动连接”。
3. 如果自动获取要求微信处于特定登录状态,请按页面提示完成登录、授权或重新检测;不要只关闭微信窗口就认为已经退出。
4. 点击开始连接,等待数据库、账号和联系人检查完成。
<p align="center">
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
</p>
通常按下面三步操作即可:
1. **确认微信数据目录**:自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径并在页面中修改。
2. **让微信停在登录页面**:如果微信已经登录,先退出当前微信账号,不只是关闭微信窗口。
3. **点击“开始连接”并按提示获取密钥**:软件准备好连接组件后会提示你登录微信;回到微信完成登录,再等待数据库、账号和联系人检查完成。
只有已经通过其他方式取得当前账号数据库密钥的高级用户,才需要选择“手动连接”。Windows 不需要关闭 SIP;macOS 是否需要额外授权或处理 SIP,以当前连接页面提示为准。
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
@@ -77,12 +107,12 @@ macOS 如果提示无法验证开发者,请按系统提示允许打开。自
- [转写微信语音](./voice.md)
- [导出聊天档案](./export.md)
- [可选开启防撤回](./recall-protection.md)
- [在微信里向 WechatExplorer 提问](../agent/agent-hub.md)
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
- [让外部 Agent 查询微信历史](../agent/overview.md)
## 7. 想直接在微信里提问
如果你希望直接在微信里向 WechatExplorer 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub
如果你希望直接在微信里向 TraceMemo 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub
1. 先完成上面的微信数据库连接,并确认“档案”里能看到聊天。
2. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
+2 -2
View File
@@ -4,7 +4,7 @@
如果你经常查同一批工作群、项目讨论或长期联系人,只靠每次临时翻聊天会越来越慢。Knowledge 会在本机建立一份可重复查找的索引,让“以前聊过什么”这类问题更容易跨会话、跨时间找到相关内容。
它不是另一个聊天窗口,也不会替你修改微信原始数据库;它是 WechatExplorer 为当前账号维护的本地加速资料。
它不是另一个聊天窗口,也不会替你修改微信原始数据库;它是 TraceMemo 为当前账号维护的本地加速资料。
## 建立和同步
@@ -34,5 +34,5 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
## 产品术语(可选)
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 WechatExplorer 的前置知识。
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
+5 -5
View File
@@ -1,6 +1,6 @@
# 数据、隐私与安全
WechatExplorer 的核心路径是本地优先,但“本地优先”不等于所有功能都完全离线。是否有数据离开电脑,取决于你是否启用了对应的 AI、Agent 或机器人能力。
TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有功能都完全离线。是否有数据离开电脑,取决于你是否启用了对应的 AI、Agent 或机器人能力。
## 默认留在本机的内容
@@ -12,9 +12,9 @@ WechatExplorer 的核心路径是本地优先,但“本地优先”不等于
- 离线语音转写;
- 导出文件生成和本地日报历史。
应用不会因为你打开 WechatExplorer 就自动把整份微信数据库上传。
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 WechatExplorer 用户数据目录保存必要的恢复记录。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
## 什么时候会请求外部服务
@@ -24,7 +24,7 @@ WechatExplorer 的核心路径是本地优先,但“本地优先”不等于
- 受控检索所需的有限上下文;
- 最终用于总结的 Evidence。
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 WechatExplorer 控制,请查看你所选服务商的政策。
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 TraceMemo 控制,请查看你所选服务商的政策。
Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的日志和配置仍由你负责。
@@ -47,7 +47,7 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
外部 Agent 通过 Reader Skill 调用本机 API,按需读取联系人、会话或时间范围内的聊天;它不会因此获得数据库文件路径或任意文件系统权限。Agent 是否把读取结果再次发送给模型,取决于 Agent 本身及其配置。
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 WechatExplorer,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 TraceMemo,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
机器人收到的文字会先进入本机 Agent Hub;如果任务需要总结或自然语言理解,受控上下文可能发送给你配置的 AI Provider。机器人账号扫码登录、个人微信数据库连接和外部 Agent/API Token 是不同的边界,使用前请分别确认账号与权限。
+5 -5
View File
@@ -1,6 +1,6 @@
# 防撤回
防撤回是一个默认关闭的可选功能。开启后,WechatExplorer 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
@@ -9,7 +9,7 @@
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
2. 打开“设置 → 防撤回”。
3. 阅读性能和数据提示后,开启“防撤回”。
4. 保持 WechatExplorer 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
@@ -18,19 +18,19 @@
- 监听应用能够识别到的后续撤回变化;
- 在本地保留必要的消息和撤回关系;
- 将已识别的原消息与撤回状态一起显示在档案中;
- 按微信账号隔离 WechatExplorer 保存的恢复记录。
- 按微信账号隔离 TraceMemo 保存的恢复记录。
## 当前限制
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
- WechatExplorer 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
## 数据写入与关闭边界
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 WechatExplorer 的用户数据目录保存必要的本地恢复记录。
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
+1 -1
View File
@@ -1,6 +1,6 @@
# 生成群聊日报和总结
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 WechatExplorer 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 TraceMemo 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
## 报告适合做什么
+17 -2
View File
@@ -2,6 +2,21 @@
先按现象定位,不要为了“重置”而直接删除微信数据库或整个应用目录。
## 安装后软件无法打开
### 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-安装并启动)。
## 连接微信失败
依次检查:
@@ -54,7 +69,7 @@ AI Search 失败时可能仍保留部分来源;不要把部分结果当成完
确认:
1. WechatExplorer 正在运行且 API Center 显示本地服务在线;
1. TraceMemo 正在运行且 API Center 显示本地服务在线;
2. Agent 使用的是当前 Reader Skill,而不是旧的 MCP 配置;
3. 请求地址为 `http://127.0.0.1:6131`
4. 非 health 请求带有最新 `Authorization: Bearer <TOKEN>`
@@ -76,4 +91,4 @@ Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
## 防撤回没有保留消息
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、WechatExplorer 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
+1 -1
View File
@@ -1,6 +1,6 @@
# 语音转文字
WechatExplorer 可以把微信语音转换成可搜索的文字,适合你不想逐条播放、希望把语音内容带入后续查找或导出的场景。
TraceMemo 可以把微信语音转换成可搜索的文字,适合你不想逐条播放、希望把语音内容带入后续查找或导出的场景。
## 使用前准备
+9 -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
@@ -25,15 +25,15 @@ extraResources:
to: resources
filter:
- '**/*'
- from: docs/skill/wechatexplorer-reader
to: skill/wechatexplorer-reader
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
- '**/*'
win:
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 preserving
# WechatExplorer as the product/shortcut name.
# electron.exe, so keep the packaged executable compatible while using
# TraceMemo as the product/shortcut name.
executableName: electron
nsis:
oneClick: false
@@ -46,10 +46,10 @@ 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.
+6 -7
View File
@@ -1,8 +1,8 @@
{
"name": "wechatexplorer",
"version": "2.1.9",
"name": "tracememo",
"version": "2.2.0",
"packageManager": "pnpm@7.33.7",
"description": "macOS / Windows 本地优先的微信聊天记录搜索与 AI 工作台",
"description": "macOS / Windows 本地优先、可追溯的 AI 微信知识与分析工作台",
"keywords": [
"wechat",
"wechat chat",
@@ -49,16 +49,15 @@
"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 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 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:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --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 --x64 --arm64 --publish always",
"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",
@@ -133,4 +132,4 @@
"ffmpeg-static"
]
}
}
}
+13 -2
View File
@@ -76,6 +76,17 @@
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%;
@@ -523,7 +534,7 @@
<div class="overview">{{OVERVIEW}}</div>
</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</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>
@@ -583,7 +594,7 @@
</section>
<footer class="footer">
数据来源:WechatExplorer · 微信群聊记录<br />
数据来源:TraceMemo · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
+13 -2
View File
@@ -69,6 +69,17 @@
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%;
@@ -695,7 +706,7 @@
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
</div>
<div class="hero-headline">
<b>{{HERO_HEADLINE}}</b>
@@ -820,7 +831,7 @@
</section>
<footer class="footer">
数据来源:WechatExplorer · 微信群聊记录<br />
数据来源:TraceMemo · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
+5 -3
View File
@@ -88,7 +88,9 @@ function validateAsarRuntimeDependencies(runtimeResources) {
const asarPath = path.join(runtimeResources, 'app.asar')
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
const entries = new Set(asar.listPackage(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`)
)
@@ -104,9 +106,9 @@ function setPlistValue(plistPath, key, value) {
}
function validateReaderSkillRuntime(runtimeResources) {
const skillPath = path.join(runtimeResources, 'skill', 'wechatexplorer-reader', 'SKILL.md')
const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
if (!existsSync(skillPath)) {
throw new Error(`Missing bundled WechatExplorer Reader Skill: ${skillPath}`)
throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`)
}
return skillPath
}
+3 -3
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
# WechatExplorer v2.1.9 Local HTTP API 手动验收脚本
# 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}/wechatexplorer-api-test.XXXXXX")"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tracememo-api-test.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
PASS_COUNT=0
@@ -18,7 +18,7 @@ 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 'WechatExplorer Local HTTP API 手动测试\n'
printf 'TraceMemo Local HTTP API 手动测试\n'
printf 'API 地址: %s\n\n' "$API_BASE_URL"
read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN
printf '\n'
+1 -1
View File
@@ -7,7 +7,7 @@ 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(), 'wechatexplorer-report-fixtures')
const outputDir = path.join(os.tmpdir(), 'tracememo-report-fixtures')
const escapeHtml = (value) =>
String(value ?? '')
+4 -3
View File
@@ -26,15 +26,15 @@ new Function('exports', 'require', 'module', output)(moduleExports, require, {
const { buildSkillInstallInstruction } = moduleExports
const local = {
type: 'local',
directoryPath: 'C:/skill/wechatexplorer-reader',
skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md',
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', '作为 WechatExplorer Reader Skill 安装'],
['openclaw', '作为 TraceMemo Reader Skill 安装'],
['generic', '读取并安装']
]) {
const text = buildSkillInstallInstruction({
@@ -44,6 +44,7 @@ for (const [target, expected] of [
})
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/)
+4 -4
View File
@@ -1,6 +1,6 @@
# WechatExplorer WeChat Connector
# TraceMemo WeChat Connector
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
This repository-local service provides the minimal WeChat bridge required by TraceMemo:
- QR-code login with a single persisted credential
- account discovery
@@ -18,8 +18,8 @@ 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`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
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). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
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.
+49 -10
View File
@@ -78,13 +78,40 @@ func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status strin
}
}
// AccountsDir returns the directory where account credentials are stored.
func AccountsDir() (string, error) {
func accountsDir(rootName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
}
// AccountsDir returns the TraceMemo directory where new credentials are stored.
func AccountsDir() (string, error) {
return accountsDir(".tracememo")
}
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
func LegacyAccountsDir() (string, error) {
return accountsDir(".wechatexplorer")
}
func accountDirectoryForID(accountID string) (string, error) {
current, err := AccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
return current, nil
}
legacy, err := LegacyAccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
return legacy, nil
}
return current, nil
}
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
@@ -159,13 +186,7 @@ func SaveCredentials(creds *Credentials) error {
return nil
}
// LoadAllCredentials loads all saved account credentials.
func LoadAllCredentials() ([]*Credentials, error) {
dir, err := AccountsDir()
if err != nil {
return nil, err
}
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
@@ -191,6 +212,24 @@ func LoadAllCredentials() ([]*Credentials, error) {
return result, nil
}
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
// untouched WechatExplorer directory for one-version upgrade compatibility.
func LoadAllCredentials() ([]*Credentials, error) {
current, err := AccountsDir()
if err != nil {
return nil, err
}
credentials, err := loadCredentialsFromDir(current)
if err != nil || len(credentials) > 0 {
return credentials, err
}
legacy, err := LegacyAccountsDir()
if err != nil {
return nil, err
}
return loadCredentialsFromDir(legacy)
}
// CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) {
return AccountsDir()
@@ -1,6 +1,7 @@
package ilink
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -34,3 +35,46 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Fatalf("old sync state still exists: %v", err)
}
}
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts")
if dir != want {
t.Fatalf("AccountsDir() = %q, want %q", dir, want)
}
}
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
legacyDir, err := LegacyAccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
t.Fatal(err)
}
legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(legacyDir); err != nil {
t.Fatalf("legacy directory changed or removed: %v", err)
}
}
+5 -5
View File
@@ -33,12 +33,12 @@ type Monitor struct {
// NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
home, err := os.UserHomeDir()
accountID := NormalizeAccountID(client.BotID())
accountsRoot, err := accountDirectoryForID(accountID)
if err != nil {
return nil, err
}
accountID := NormalizeAccountID(client.BotID())
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
m := &Monitor{
client: client,
@@ -73,7 +73,7 @@ func (m *Monitor) Run(ctx context.Context) error {
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
m.failures, maxConsecutiveFailures, backoff, err)
if m.failures == maxConsecutiveFailures {
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures)
}
select {
case <-time.After(backoff):
@@ -96,7 +96,7 @@ func (m *Monitor) Run(ctx context.Context) error {
} else {
// Sync buf already empty but still getting session expired:
// the bot token itself has expired. The user needs to re-login.
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.")
}
select {
case <-time.After(sessionExpiredBackoff):
+16 -2
View File
@@ -20,6 +20,7 @@ interface TokenReadResult {
export class ApiTokenStore {
private cachedToken: string | null = null
private automaticGenerationBlockedReason: string | null = null
constructor(private readonly filePathOverride?: string) {}
@@ -42,10 +43,18 @@ export class ApiTokenStore {
available: true,
hasToken: result.success && Boolean(result.token),
maskedToken: MASKED_TOKEN,
...(result.success ? {} : { error: result.error })
...(result.success
? result.token || !this.automaticGenerationBlockedReason
? {}
: { error: this.automaticGenerationBlockedReason }
: { error: result.error })
}
}
setAutomaticGenerationBlocked(reason?: string): void {
this.automaticGenerationBlockedReason = reason?.trim() || null
}
ensureToken(): ApiTokenActionResult {
if (!safeStorage.isEncryptionAvailable()) {
return {
@@ -59,6 +68,9 @@ export class ApiTokenStore {
const current = this.read()
if (!current.success) return this.actionError(current.error)
if (current.token) return this.actionSuccess()
if (this.automaticGenerationBlockedReason) {
return this.actionError(this.automaticGenerationBlockedReason)
}
return this.persist(this.generateToken())
}
@@ -70,7 +82,9 @@ export class ApiTokenStore {
rotateToken(): ApiTokenActionResult {
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
return this.persist(this.generateToken())
const result = this.persist(this.generateToken())
if (result.success) this.automaticGenerationBlockedReason = null
return result
}
getTokenForAuthentication(): string | null {
+56
View File
@@ -0,0 +1,56 @@
import { app } from 'electron'
import path from 'path'
import { getUserDataRoots, LEGACY_USER_DATA_NAME, TRACE_MEMO_RUNTIME_NAME } from './app-data-paths'
export const LEGACY_MIGRATION_HELPER_ENV = 'TRACEMEMO_LEGACY_MIGRATION_HELPER'
export const LEGACY_MIGRATION_SOURCE_ENV = 'TRACEMEMO_LEGACY_MIGRATION_SOURCE'
export const LEGACY_MIGRATION_USER_DATA_ENV = 'TRACEMEMO_LEGACY_MIGRATION_USER_DATA'
export const LEGACY_MIGRATION_RESULT_FD_ENV = 'TRACEMEMO_LEGACY_MIGRATION_RESULT_FD'
// This module must remain the first main-process import. Static imports in
// settings/cache services can otherwise resolve Electron paths before the
// TraceMemo userData is installed before any consumers. macOS uses the old
// safeStorage identity only inside the helper; Windows keeps in both
// processes because WCDB requires that technical runtime name.
const isLegacyMigrationHelper = process.env[LEGACY_MIGRATION_HELPER_ENV] === '1'
const legacyRuntimeName = process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME
const runtimeName =
process.platform === 'win32'
? 'WeFlow'
: isLegacyMigrationHelper
? legacyRuntimeName
: TRACE_MEMO_RUNTIME_NAME
app.setName(runtimeName)
const isolatedUserData = process.env['WXE_USER_DATA']
const isUserDataIsolated = !isLegacyMigrationHelper && Boolean(isolatedUserData?.trim())
const roots = getUserDataRoots(app.getPath('appData'))
const helperUserData = process.env[LEGACY_MIGRATION_USER_DATA_ENV]
const selectedUserData = isLegacyMigrationHelper
? path.resolve(helperUserData?.trim() || path.join(roots.current, '.legacy-migration-helper'))
: isolatedUserData?.trim()
? path.resolve(isolatedUserData)
: roots.current
app.setPath('userData', selectedUserData)
app.setPath('sessionData', selectedUserData)
// Logs are intentionally independent from userData. New TraceMemo logs go to
// the new visible directory while historical WechatExplorer logs remain in
// place and are never moved or renamed.
if (isLegacyMigrationHelper) {
app.setPath('logs', path.join(selectedUserData, 'logs'))
} else if (process.platform === 'darwin') {
app.setPath('logs', path.join(app.getPath('home'), 'Library', 'Logs', 'TraceMemo'))
} else if (process.platform === 'win32') {
app.setPath('logs', path.join(selectedUserData, 'logs'))
}
export {
isLegacyMigrationHelper,
isUserDataIsolated,
legacyRuntimeName,
runtimeName,
roots,
selectedUserData
}
+676
View File
@@ -0,0 +1,676 @@
import { app, BrowserWindow, dialog, safeStorage } from 'electron'
import { spawn } from 'child_process'
import { constants as fsConstants } from 'fs'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { randomUUID } from 'crypto'
import {
LEGACY_MIGRATION_HELPER_ENV,
LEGACY_MIGRATION_RESULT_FD_ENV,
LEGACY_MIGRATION_SOURCE_ENV,
LEGACY_MIGRATION_USER_DATA_ENV
} from './app-data-bootstrap'
import {
hasValidUserAssets,
selectUserDataRoot,
type UserDataRoots,
type UserDataSelection
} from './app-data-paths'
import type { LegacySecretBundle } from './legacy-safe-storage-helper'
const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json'
const TOKEN_MIGRATION_BLOCK_MESSAGE =
'检测到旧版 API Token 尚未完成迁移。请重试数据迁移,或在 API Center 主动重新生成 Token。'
const FILE_ASSETS = [
'settings.json',
'ai-providers.json',
'image-insights.json',
'group-exit-monitor.json'
] as const
const DIRECTORY_ASSETS = [
'knowledge',
'reports',
'recall-archive',
'Local Storage',
'digital-twin',
'group-exit-monitor'
] as const
export type MigrationItemStatus = 'migrated' | 'skipped' | 'missing' | 'failed'
export type MigrationStatus = 'deferred' | 'in-progress' | 'partial' | 'completed'
export interface MigrationState {
version: 1
status: MigrationStatus
sourceRoot: string
updatedAt: string
items: Record<string, MigrationItemStatus>
secretFailures: string[]
}
export interface MigrationAssessment {
selection: UserDataSelection
sourceRoot?: string
currentHasAssets: boolean
state?: MigrationState
shouldPrompt: boolean
reason:
| 'clean-install'
| 'legacy-empty'
| 'current-data-present'
| 'migration-completed'
| 'legacy-assets-detected'
| 'migration-resumable'
}
export interface MigrationExecutionResult {
state: MigrationState
tokenGenerationBlocked: boolean
tokenBlockReason?: string
}
export interface MigrationFlowResult {
assessment: MigrationAssessment
action: 'none' | 'deferred' | 'migrated'
execution?: MigrationExecutionResult
tokenGenerationBlocked: boolean
tokenBlockReason?: string
}
export interface MigrationDependencies {
decryptLegacySecrets: (sourceRoot: string) => Promise<LegacySecretBundle>
agentRoots: () => { legacy: string; current: string }
now: () => Date
onProgress?: (message: string) => void
}
function migrationStatePath(targetRoot: string): string {
return path.join(targetRoot, MIGRATION_STATE_FILE)
}
function readMigrationState(targetRoot: string): MigrationState | undefined {
try {
const state = fs.readJsonSync(migrationStatePath(targetRoot)) as MigrationState
if (
state.version !== 1 ||
!['deferred', 'in-progress', 'partial', 'completed'].includes(state.status) ||
typeof state.sourceRoot !== 'string'
) {
return undefined
}
return state
} catch {
return undefined
}
}
async function writeMigrationState(targetRoot: string, state: MigrationState): Promise<void> {
await fs.ensureDir(targetRoot)
const statePath = migrationStatePath(targetRoot)
const tempPath = `${statePath}.tmp-${process.pid}-${randomUUID()}`
try {
await fs.writeJson(tempPath, state, { spaces: 2, mode: 0o600 })
await fs.chmod(tempPath, 0o600)
await fs.move(tempPath, statePath, { overwrite: true })
} finally {
await fs.remove(tempPath).catch(() => undefined)
}
}
function isLegacySelection(selection: UserDataSelection): boolean {
return selection.selectedKind === 'legacy-display' || selection.selectedKind === 'legacy-package'
}
export function assessMigration(roots: UserDataRoots): MigrationAssessment {
const selection = selectUserDataRoot(roots)
const state = readMigrationState(roots.current)
const selectedLegacyRoot = isLegacySelection(selection) ? selection.selected : undefined
const stateSource = state?.sourceRoot
const resumableStateSource =
stateSource &&
[roots.legacy, roots.legacyPackage].includes(stateSource) &&
hasValidUserAssets(stateSource)
? stateSource
: undefined
const sourceRoot = resumableStateSource || selectedLegacyRoot
const currentHasAssets = hasValidUserAssets(roots.current)
if (state?.status === 'completed') {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: false,
reason: 'migration-completed'
}
}
if (sourceRoot && state && ['deferred', 'in-progress', 'partial'].includes(state.status)) {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: true,
reason: 'migration-resumable'
}
}
if (!sourceRoot) {
return {
selection,
currentHasAssets,
state,
shouldPrompt: false,
reason:
selection.directories.legacy || selection.directories.legacyPackage
? 'legacy-empty'
: 'clean-install'
}
}
if (currentHasAssets) {
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: false,
reason: 'current-data-present'
}
}
return {
selection,
sourceRoot,
currentHasAssets,
state,
shouldPrompt: true,
reason: 'legacy-assets-detected'
}
}
async function copyFileWithoutOverwrite(
sourceRoot: string,
targetRoot: string,
relativePath: string,
stagingRoot: string
): Promise<MigrationItemStatus> {
const sourcePath = path.join(sourceRoot, relativePath)
const targetPath = path.join(targetRoot, relativePath)
if (!(await fs.pathExists(sourcePath))) return 'missing'
if (await fs.pathExists(targetPath)) return 'skipped'
const stagedPath = path.join(stagingRoot, relativePath)
await fs.ensureDir(path.dirname(stagedPath))
await fs.copy(sourcePath, stagedPath, {
overwrite: false,
errorOnExist: true,
preserveTimestamps: true
})
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.ensureDir(path.dirname(targetPath))
await fs.move(stagedPath, targetPath, { overwrite: false })
return 'migrated'
}
async function integrityCheckKnowledgeDatabase(databasePath: string): Promise<void> {
const script = `
const { DatabaseSync } = require('node:sqlite')
const database = new DatabaseSync(process.argv[1], { readOnly: true })
try {
const rows = database.prepare('PRAGMA integrity_check').all()
const result = String(rows[0]?.integrity_check || rows[0]?.['integrity_check(1)'] || '').toLowerCase()
if (rows.length !== 1 || result !== 'ok') process.exitCode = 2
} finally {
database.close()
}
`
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, ['-e', script, databasePath], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
let stderr = ''
child.stderr?.on('data', (chunk: Buffer) => {
if (stderr.length < 4096) stderr += chunk.toString('utf8')
})
child.once('error', reject)
child.once('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`Knowledge SQLite integrity_check failed (${code}): ${stderr.trim()}`))
})
})
}
async function verifyKnowledgeCopy(sourcePath: string, stagedPath: string): Promise<void> {
const accounts = await fs.readdir(sourcePath, { withFileTypes: true })
for (const account of accounts) {
if (!account.isDirectory()) continue
const sourceDatabase = path.join(sourcePath, account.name, 'knowledge.sqlite')
if (!(await fs.pathExists(sourceDatabase))) continue
const stagedDatabase = path.join(stagedPath, account.name, 'knowledge.sqlite')
if (!(await fs.pathExists(stagedDatabase)))
throw new Error('Knowledge database copy is missing')
for (const suffix of ['', '-wal', '-shm']) {
const sourceFile = `${sourceDatabase}${suffix}`
if (!(await fs.pathExists(sourceFile))) continue
const stagedFile = `${stagedDatabase}${suffix}`
if (!(await fs.pathExists(stagedFile)))
throw new Error(`Knowledge companion missing: ${suffix}`)
const [sourceStat, stagedStat] = await Promise.all([fs.stat(sourceFile), fs.stat(stagedFile)])
if (sourceStat.size !== stagedStat.size)
throw new Error(`Knowledge copy size mismatch: ${suffix}`)
}
await integrityCheckKnowledgeDatabase(stagedDatabase)
}
}
async function copyDirectoryWithoutOverwrite(
sourceRoot: string,
targetRoot: string,
relativePath: string,
stagingRoot: string
): Promise<MigrationItemStatus> {
const sourcePath = path.join(sourceRoot, relativePath)
const targetPath = path.join(targetRoot, relativePath)
if (!(await fs.pathExists(sourcePath))) return 'missing'
if (await fs.pathExists(targetPath)) return 'skipped'
const stagedPath = path.join(stagingRoot, relativePath)
await fs.ensureDir(path.dirname(stagedPath))
await fs.copy(sourcePath, stagedPath, {
overwrite: false,
errorOnExist: true,
preserveTimestamps: true
})
if (relativePath === 'knowledge') await verifyKnowledgeCopy(sourcePath, stagedPath)
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.move(stagedPath, targetPath, { overwrite: false })
return 'migrated'
}
async function writeEncryptedWithoutOverwrite(
targetRoot: string,
relativePath: string,
plainText: string
): Promise<MigrationItemStatus> {
const targetPath = path.join(targetRoot, relativePath)
if (await fs.pathExists(targetPath)) return 'skipped'
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`
try {
await fs.ensureDir(path.dirname(targetPath))
await fs.writeFile(tempPath, safeStorage.encryptString(plainText), { mode: 0o600 })
await fs.chmod(tempPath, 0o600)
if (await fs.pathExists(targetPath)) return 'skipped'
await fs.move(tempPath, targetPath, { overwrite: false })
return 'migrated'
} finally {
await fs.remove(tempPath).catch(() => undefined)
}
}
async function copyMissingTree(
sourceRoot: string,
targetRoot: string
): Promise<MigrationItemStatus> {
if (!(await fs.pathExists(sourceRoot))) return 'missing'
let copied = false
const visit = async (sourceDirectory: string, targetDirectory: string): Promise<void> => {
await fs.ensureDir(targetDirectory)
for (const entry of await fs.readdir(sourceDirectory, { withFileTypes: true })) {
const sourcePath = path.join(sourceDirectory, entry.name)
const targetPath = path.join(targetDirectory, entry.name)
if (entry.isDirectory()) {
await visit(sourcePath, targetPath)
} else if (entry.isFile() && !(await fs.pathExists(targetPath))) {
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL)
const stat = await fs.stat(sourcePath)
await fs.chmod(targetPath, stat.mode & 0o777)
copied = true
}
}
}
await visit(sourceRoot, targetRoot)
return copied ? 'migrated' : 'skipped'
}
export function getAgentCredentialRoots(homePath = os.homedir()): {
legacy: string
current: string
} {
return {
legacy: path.join(homePath, '.wechatexplorer', 'wechat-connector', 'accounts'),
current: path.join(homePath, '.tracememo', 'wechat-connector', 'accounts')
}
}
async function hasEncryptedLegacyAssets(sourceRoot: string): Promise<boolean> {
for (const relativePath of [
'local-api-token.bin',
'ai-provider-keys.bin',
'wechat-image-keys.bin',
'wechat-db-key.bin'
]) {
if (await fs.pathExists(path.join(sourceRoot, relativePath))) return true
}
const databaseKeyRoot = path.join(sourceRoot, 'database-keys')
if (!(await fs.pathExists(databaseKeyRoot))) return false
return (await fs.readdir(databaseKeyRoot, { withFileTypes: true })).some(
(entry) => entry.isFile() && entry.name.endsWith('.bin')
)
}
export async function runLegacySecretHelper(sourceRoot: string): Promise<LegacySecretBundle> {
const helperUserData = await fs.mkdtemp(
path.join(app.getPath('temp'), 'tracememo-legacy-safe-storage-')
)
const helperArgs = app.isPackaged ? [] : process.argv.slice(1)
try {
return await new Promise<LegacySecretBundle>((resolve, reject) => {
const child = spawn(process.execPath, helperArgs, {
env: {
...process.env,
[LEGACY_MIGRATION_HELPER_ENV]: '1',
[LEGACY_MIGRATION_SOURCE_ENV]: sourceRoot,
[LEGACY_MIGRATION_USER_DATA_ENV]: helperUserData,
[LEGACY_MIGRATION_RESULT_FD_ENV]: '3'
},
stdio: ['ignore', 'ignore', 'pipe', 'pipe'],
windowsHide: true
})
const chunks: Buffer[] = []
let totalBytes = 0
let settled = false
const finish = (error?: Error, value?: LegacySecretBundle): void => {
if (settled) return
settled = true
clearTimeout(timeout)
if (error) reject(error)
else resolve(value!)
}
const resultPipe = child.stdio[3]
resultPipe?.on('data', (chunk: Buffer) => {
totalBytes += chunk.length
if (totalBytes > 5 * 1024 * 1024) {
child.kill()
finish(new Error('legacy secret helper result is too large'))
return
}
chunks.push(chunk)
})
child.once('error', (error) => finish(error))
child.once('exit', (code) => {
if (code !== 0) {
finish(new Error(`legacy secret helper exited with code ${code}`))
return
}
try {
finish(
undefined,
JSON.parse(Buffer.concat(chunks).toString('utf8')) as LegacySecretBundle
)
} catch {
finish(new Error('legacy secret helper returned invalid data'))
}
})
const timeout = setTimeout(() => {
child.kill()
finish(new Error('legacy secret helper timed out'))
}, 30_000)
})
} finally {
await fs.remove(helperUserData).catch(() => undefined)
}
}
export async function executeMigration(
sourceRoot: string,
targetRoot: string,
dependencies: MigrationDependencies = {
decryptLegacySecrets: runLegacySecretHelper,
agentRoots: () => getAgentCredentialRoots(),
now: () => new Date()
}
): Promise<MigrationExecutionResult> {
const items: Record<string, MigrationItemStatus> = {}
const secretFailures: string[] = []
const timestamp = (): string => dependencies.now().toISOString()
const state: MigrationState = {
version: 1,
status: 'in-progress',
sourceRoot,
updatedAt: timestamp(),
items,
secretFailures
}
await writeMigrationState(targetRoot, state)
const stagingRoot = path.join(targetRoot, '.tracememo-migration-staging-v1')
await fs.remove(stagingRoot).catch(() => undefined)
const runItem = async (name: string, task: () => Promise<MigrationItemStatus>): Promise<void> => {
dependencies.onProgress?.(
name === 'knowledge' ? '正在安全迁移 Knowledge,数据较大时需要几分钟…' : `正在迁移 ${name}`
)
try {
items[name] = await task()
} catch {
items[name] = 'failed'
}
state.updatedAt = timestamp()
await writeMigrationState(targetRoot, state)
}
try {
for (const relativePath of FILE_ASSETS) {
await runItem(relativePath, () =>
copyFileWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
)
}
for (const relativePath of DIRECTORY_ASSETS) {
await runItem(relativePath, () =>
copyDirectoryWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
)
}
const agentRoots = dependencies.agentRoots()
await runItem('agent-credentials', () => copyMissingTree(agentRoots.legacy, agentRoots.current))
let secrets: LegacySecretBundle = { databaseKeys: {}, failures: [] }
if (await hasEncryptedLegacyAssets(sourceRoot)) {
try {
secrets = await dependencies.decryptLegacySecrets(sourceRoot)
secretFailures.push(...secrets.failures.map((failure) => failure.asset))
} catch {
secretFailures.push('safeStorage-helper')
}
}
const migrateSecret = async (
relativePath: string,
plainText: string | undefined
): Promise<void> => {
const sourceExists = await fs.pathExists(path.join(sourceRoot, relativePath))
if (!sourceExists) {
items[relativePath] = 'missing'
return
}
if (plainText === undefined) {
items[relativePath] = 'failed'
if (!secretFailures.includes(relativePath)) secretFailures.push(relativePath)
return
}
await runItem(relativePath, () =>
writeEncryptedWithoutOverwrite(targetRoot, relativePath, plainText)
)
}
await migrateSecret('local-api-token.bin', secrets.token)
await migrateSecret(
'ai-provider-keys.bin',
secrets.aiProviderKeys ? JSON.stringify(secrets.aiProviderKeys) : undefined
)
await migrateSecret(
'wechat-image-keys.bin',
secrets.imageKeys ? JSON.stringify(secrets.imageKeys) : undefined
)
await migrateSecret('wechat-db-key.bin', secrets.legacyDatabaseKey)
const databaseKeySource = path.join(sourceRoot, 'database-keys')
if (await fs.pathExists(databaseKeySource)) {
for (const entry of await fs.readdir(databaseKeySource, { withFileTypes: true })) {
if (!entry.isFile() || !/^[0-9a-f]{64}\.bin$/i.test(entry.name)) continue
const relativePath = path.join('database-keys', entry.name)
await migrateSecret(relativePath, secrets.databaseKeys[entry.name])
}
}
} finally {
await fs.remove(stagingRoot).catch(() => undefined)
}
const failed = Object.values(items).some((status) => status === 'failed')
state.status = failed || secretFailures.length > 0 ? 'partial' : 'completed'
state.updatedAt = timestamp()
state.secretFailures = Array.from(new Set(secretFailures))
await writeMigrationState(targetRoot, state)
const tokenGenerationBlocked =
(await fs.pathExists(path.join(sourceRoot, 'local-api-token.bin'))) &&
!(await fs.pathExists(path.join(targetRoot, 'local-api-token.bin')))
return {
state,
tokenGenerationBlocked,
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
}
}
async function createMigrationProgressWindow(): Promise<BrowserWindow | null> {
if (process.platform !== 'darwin') return null
const window = new BrowserWindow({
width: 460,
height: 260,
show: false,
resizable: false,
minimizable: false,
maximizable: false,
closable: false,
title: 'TraceMemo 数据迁移',
backgroundColor: '#f5f5f7',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><style>
html,body{height:100%;margin:0}body{display:grid;place-items:center;background:#f5f5f7;color:#202124;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center}
main{width:360px}.spinner{width:30px;height:30px;margin:0 auto 20px;border:3px solid #d9dddf;border-top-color:#00796b;border-radius:50%;animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
h1{font-size:18px;margin:0 0 12px}p{line-height:1.6;margin:0}.hint{margin-top:14px;color:#697177;font-size:12px}
</style></head><body><main><div class="spinner"></div><h1>正在迁移 WechatExplorer 数据</h1><p id="status">正在准备迁移…</p><p class="hint">请不要退出 TraceMemo。旧数据不会被删除。</p></main></body></html>`
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
window.setProgressBar(2, { mode: 'indeterminate' })
window.show()
return window
}
function updateMigrationProgress(window: BrowserWindow | null, message: string): void {
if (!window || window.isDestroyed()) return
void window.webContents
.executeJavaScript(
`document.getElementById('status').textContent = ${JSON.stringify(message)}`,
true
)
.catch(() => undefined)
}
function closeMigrationProgress(window: BrowserWindow | null): void {
if (!window || window.isDestroyed()) return
window.setProgressBar(-1)
window.setClosable(true)
window.destroy()
}
export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<MigrationFlowResult> {
const assessment = assessMigration(roots)
if (!assessment.shouldPrompt || !assessment.sourceRoot) {
return { assessment, action: 'none', tokenGenerationBlocked: false }
}
const sourceLabel = path.basename(assessment.sourceRoot)
const conflictNote = assessment.selection.legacyConflict
? '\n\n检测到两个旧数据目录,将确定性使用 WechatExplorer;另一个目录不会修改。'
: ''
const response = await dialog.showMessageBox({
type: 'question',
title: '迁移 WechatExplorer 数据',
message: '检测到 WechatExplorer 数据',
detail:
`TraceMemo 可以从 ${sourceLabel} 迁移设置、Knowledge、本地索引、API Token、AI Provider、报告和 Agent 配置。` +
'\n\n迁移只复制缺失的用户资产,不会覆盖 TraceMemo 已有数据,也不会删除旧目录。' +
conflictNote,
buttons: ['立即迁移', '以后迁移'],
defaultId: 0,
cancelId: 1,
noLink: true
})
if (response.response !== 0) {
const state: MigrationState = {
version: 1,
status: 'deferred',
sourceRoot: assessment.sourceRoot,
updatedAt: new Date().toISOString(),
items: assessment.state?.items || {},
secretFailures: assessment.state?.secretFailures || []
}
await writeMigrationState(roots.current, state)
const tokenGenerationBlocked = await fs.pathExists(
path.join(assessment.sourceRoot, 'local-api-token.bin')
)
return {
assessment,
action: 'deferred',
tokenGenerationBlocked,
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
}
}
const progressWindow = await createMigrationProgressWindow()
let execution: MigrationExecutionResult
try {
execution = await executeMigration(assessment.sourceRoot, roots.current, {
decryptLegacySecrets: runLegacySecretHelper,
agentRoots: () => getAgentCredentialRoots(),
now: () => new Date(),
onProgress: (message) => updateMigrationProgress(progressWindow, message)
})
updateMigrationProgress(progressWindow, '迁移完成,正在启动 TraceMemo…')
const messageBoxOptions = {
type: execution.state.status === 'completed' ? ('info' as const) : ('warning' as const),
title: 'TraceMemo 数据迁移',
message:
execution.state.status === 'completed'
? 'WechatExplorer 数据迁移完成'
: '部分数据未能迁移',
detail:
execution.state.status === 'completed'
? '核心用户资产已复制到 TraceMemo。旧目录仍完整保留。'
: '旧目录没有被修改。请保留旧数据并在下次启动时重试;无法迁移的 API Token 不会被静默替换。',
buttons: ['好']
}
if (progressWindow && !progressWindow.isDestroyed()) {
await dialog.showMessageBox(progressWindow, messageBoxOptions)
} else {
await dialog.showMessageBox(messageBoxOptions)
}
} finally {
closeMigrationProgress(progressWindow)
}
return {
assessment,
action: 'migrated',
execution,
tokenGenerationBlocked: execution.tokenGenerationBlocked,
tokenBlockReason: execution.tokenBlockReason
}
}
+252
View File
@@ -0,0 +1,252 @@
import fs from 'fs'
import path from 'path'
export const LEGACY_USER_DATA_NAME = 'WechatExplorer'
export const LEGACY_PACKAGE_USER_DATA_NAME = 'wechatexplorer'
export const CURRENT_USER_DATA_NAME = 'TraceMemo'
export const TRACE_MEMO_RUNTIME_NAME = 'TraceMemo'
export interface UserDataRoots {
legacy: string
legacyPackage: string
current: string
}
export interface UserDataSelectionInput extends UserDataRoots {
isolated?: string
}
export type UserDataRootKind = 'isolated' | 'legacy-display' | 'legacy-package' | 'current'
export type UserDataSelectionReason =
| 'isolated-override'
| 'legacy-display-assets'
| 'legacy-package-assets'
| 'legacy-shared-assets'
| 'legacy-conflict-display-preferred'
| 'current-assets'
| 'clean-install'
export interface UserDataSelection {
selected: string
selectedKind: UserDataRootKind
reason: UserDataSelectionReason
directories: {
legacy: boolean
legacyPackage: boolean
current: boolean
}
assets: {
legacy: boolean
legacyPackage: boolean
current: boolean
}
legacyRootsEquivalent: boolean
legacyConflict: boolean
}
export interface UserDataSelectionDependencies {
directoryExists: (root: string) => boolean
hasAssets: (root: string) => boolean
areSameDirectory: (first: string, second: string) => boolean
}
function isNonEmptyFile(filePath: string): boolean {
try {
const stat = fs.statSync(filePath)
return stat.isFile() && stat.size > 0
} catch {
return false
}
}
function hasPersistentEntries(directoryPath: string): boolean {
try {
return fs.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => {
if (entry.name === '.DS_Store') return false
if (entry.name === 'LOCK' || entry.name === 'LOG' || entry.name === 'LOG.old') return false
return entry.isFile() || entry.isDirectory()
})
} catch {
return false
}
}
function hasDatabaseKey(directoryPath: string): boolean {
try {
return fs.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => {
return (
entry.isFile() &&
entry.name.endsWith('.bin') &&
isNonEmptyFile(path.join(directoryPath, entry.name))
)
})
} catch {
return false
}
}
function hasKnowledgeDatabase(root: string): boolean {
const knowledgeRoot = path.join(root, 'knowledge')
try {
return fs.readdirSync(knowledgeRoot, { withFileTypes: true }).some((entry) => {
if (!entry.isDirectory()) return false
return isNonEmptyFile(path.join(knowledgeRoot, entry.name, 'knowledge.sqlite'))
})
} catch {
return false
}
}
export function isExistingDirectory(directoryPath: string): boolean {
try {
return fs.statSync(directoryPath).isDirectory()
} catch {
return false
}
}
export function areSameExistingDirectory(firstPath: string, secondPath: string): boolean {
try {
const first = fs.statSync(firstPath)
const second = fs.statSync(secondPath)
if (!first.isDirectory() || !second.isDirectory()) return false
if (first.ino && first.dev === second.dev && first.ino === second.ino) return true
return fs.realpathSync.native(firstPath) === fs.realpathSync.native(secondPath)
} catch {
return false
}
}
/**
* Runtime-only Chromium files are deliberately excluded. A directory is a
* valid data root only when it contains at least one user-owned marker.
*/
export function hasValidUserAssets(root: string): boolean {
const markers = [
'settings.json',
'ai-providers.json',
'ai-provider-keys.bin',
'local-api-token.bin',
'wechat-db-key.bin',
'wechat-image-keys.bin',
'image-insights.json',
'wechat-share-service.bin'
]
if (markers.some((marker) => isNonEmptyFile(path.join(root, marker)))) return true
if (hasKnowledgeDatabase(root)) return true
if (hasDatabaseKey(path.join(root, 'database-keys'))) return true
if (hasPersistentEntries(path.join(root, 'reports'))) return true
if (hasPersistentEntries(path.join(root, 'recall-archive'))) return true
if (hasPersistentEntries(path.join(root, 'digital-twin'))) return true
if (hasPersistentEntries(path.join(root, 'group-exit-monitor'))) return true
if (hasPersistentEntries(path.join(root, 'Local Storage', 'leveldb'))) return true
return false
}
export function getUserDataRoots(appDataPath: string): UserDataRoots {
return {
legacy: path.join(appDataPath, LEGACY_USER_DATA_NAME),
legacyPackage: path.join(appDataPath, LEGACY_PACKAGE_USER_DATA_NAME),
current: path.join(appDataPath, CURRENT_USER_DATA_NAME)
}
}
/**
* Select exactly one root. This intentionally does not copy, merge, delete or
* modify any directory. The visible-name legacy root remains first priority;
* the lowercase v2.1.9 package-name root is the fallback on case-sensitive
* filesystems. If both distinct legacy roots contain assets, the visible-name
* root wins deterministically and the caller receives conflict diagnostics.
*/
export function selectUserDataRoot(
input: UserDataSelectionInput,
dependencies: UserDataSelectionDependencies = {
directoryExists: isExistingDirectory,
hasAssets: hasValidUserAssets,
areSameDirectory: areSameExistingDirectory
}
): UserDataSelection {
const isolated = input.isolated?.trim()
if (isolated) {
return {
selected: path.resolve(isolated),
selectedKind: 'isolated',
reason: 'isolated-override',
directories: { legacy: false, legacyPackage: false, current: false },
assets: { legacy: false, legacyPackage: false, current: false },
legacyRootsEquivalent: false,
legacyConflict: false
}
}
const directories = {
legacy: dependencies.directoryExists(input.legacy),
legacyPackage: dependencies.directoryExists(input.legacyPackage),
current: dependencies.directoryExists(input.current)
}
const assets = {
legacy: dependencies.hasAssets(input.legacy),
legacyPackage: dependencies.hasAssets(input.legacyPackage),
current: dependencies.hasAssets(input.current)
}
const legacyRootsEquivalent =
directories.legacy &&
directories.legacyPackage &&
dependencies.areSameDirectory(input.legacy, input.legacyPackage)
if (assets.legacy) {
const legacyConflict = assets.legacyPackage && !legacyRootsEquivalent
return {
selected: input.legacy,
selectedKind: 'legacy-display',
reason: legacyConflict
? 'legacy-conflict-display-preferred'
: legacyRootsEquivalent
? 'legacy-shared-assets'
: 'legacy-display-assets',
directories,
assets,
legacyRootsEquivalent,
legacyConflict
}
}
if (assets.legacyPackage) {
return {
selected: input.legacyPackage,
selectedKind: 'legacy-package',
reason: 'legacy-package-assets',
directories,
assets,
legacyRootsEquivalent: false,
legacyConflict: false
}
}
if (assets.current) {
return {
selected: input.current,
selectedKind: 'current',
reason: 'current-assets',
directories,
assets,
legacyRootsEquivalent: false,
legacyConflict: false
}
}
return {
selected: input.current,
selectedKind: 'current',
reason: 'clean-install',
directories,
assets,
legacyRootsEquivalent: false,
legacyConflict: false
}
}
export function chooseUserDataRoot(input: UserDataSelectionInput): string {
return selectUserDataRoot(input).selected
}
+1 -1
View File
@@ -34,7 +34,7 @@ export class AppLogger {
}
get logPath(): string {
return path.join(this.logDir, 'wechatexplorer.log')
return path.join(this.logDir, 'tracememo.log')
}
private rotateIfNeeded(): void {
+389 -26
View File
@@ -21,8 +21,10 @@ import { VideoAssetService } from './video-asset-service'
import { StickerService } from './sticker-service'
import { getImageExportAttempts } from '../shared/export-media'
import { FileAssetService } from './file-asset-service'
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-cache'
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import { imageFileQuality } from '../shared/image-quality'
import { resolveMemberName } from '../shared/member-names'
const jobs = new Set<string>()
const activeArchives = new Map<string, Archiver>()
@@ -42,6 +44,18 @@ const exportStamp = (): string => {
const pad = (value: number): string => String(value).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
const defaultExportRoot = (): string => join(app.getPath('documents'), 'TraceMemo', '导出')
const legacyExportRoot = (): string => join(app.getPath('documents'), 'WechatExplorer', '导出')
const resolveDefaultExportRoot = async (outputFolder?: string): Promise<string> => {
if (!outputFolder) return defaultExportRoot()
try {
await fs.access(join(legacyExportRoot(), outputFolder))
// Continue incremental exports in the legacy folder when it already exists.
return legacyExportRoot()
} catch {
return defaultExportRoot()
}
}
const imageKeys = new ImageKeyConfigService()
export interface HtmlExportConversation {
@@ -250,6 +264,7 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
'exportMediaUrl',
'exportMediaType',
'exportMediaName',
'exportMediaQuality',
'exportAvatarUrl'
]
for (const key of preserveWhenMissing) {
@@ -319,7 +334,8 @@ export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] {
export async function readHtmlArchive(
outputDir: string,
targets: ExportTarget[],
name: string
name: string,
allowTargetChanges = false
): Promise<HtmlExportArchive> {
const dataPath = join(outputDir, 'data', 'messages.js')
let source = ''
@@ -381,7 +397,7 @@ export async function readHtmlArchive(
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
.map((conversation) => conversation.id)
.sort()
if (actualIds.join('|') !== expectedIds.join('|')) {
if (!allowTargetChanges && actualIds.join('|') !== expectedIds.join('|')) {
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
}
return {
@@ -461,6 +477,20 @@ export async function writeHtmlArchive(
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
const source = `${archiveDataPrefix}${JSON.stringify(archive)};\n`
await fs.writeFile(
join(dataDir, 'conversations.json'),
JSON.stringify(
{
version: archive.version,
name: archive.name,
exportedAt: archive.exportedAt,
conversations: archive.conversations
},
null,
2
),
'utf8'
)
await fs.writeFile(temporaryPath, source, 'utf8')
try {
await fs.rename(temporaryPath, dataPath)
@@ -622,19 +652,129 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
].join('\n')
}
export async function runExport(
interface SingleExportOptions {
outputRoot?: string
outputFolderName?: string
manageJob?: boolean
sendProgress?: (progress: ExportJobProgress) => void
selfInfo?: CachedSelfInfo | null
}
interface AllExportManifestEntry {
id: string
name: string
type: ExportTarget['type']
folder: string
messageCount: number
}
const writeAllExportManifest = async (
outputDir: string,
conversations: AllExportManifestEntry[],
messageCount: number,
status: 'running' | 'completed' | 'cancelled' | 'failed',
error?: string
): Promise<void> => {
const manifestPath = join(outputDir, '导出清单.json')
const temporaryPath = `${manifestPath}.tmp-${process.pid}-${Date.now()}`
await fs.writeFile(
temporaryPath,
JSON.stringify(
{
version: 1,
status,
exportedAt: new Date().toISOString(),
messageCount,
conversations,
error
},
null,
2
),
'utf8'
)
try {
await fs.rename(temporaryPath, manifestPath)
} catch (error) {
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
await fs.rm(manifestPath, { force: true })
await fs.rename(temporaryPath, manifestPath)
} finally {
await fs.rm(temporaryPath, { force: true })
}
}
const conversationFolderNames = (targets: ExportTarget[]): Map<string, string> => {
const names = new Map<string, string>()
const used = new Set<string>()
for (const target of targets) {
const base = safeFilePart(target.name).slice(0, 80).trim() || '未命名聊天'
let candidate = base
const usedKey = (value: string): string => `${target.type}:${value.toLowerCase()}`
if (used.has(usedKey(candidate))) {
candidate = `${base}_${hashPart(target.userMd5, 8)}`
}
let suffix = 2
while (used.has(usedKey(candidate))) {
candidate = `${base}_${hashPart(target.userMd5, 8)}_${suffix}`
suffix += 1
}
used.add(usedKey(candidate))
names.set(target.userMd5, candidate)
}
return names
}
const pathExists = async (value: string): Promise<boolean> => {
try {
await fs.stat(value)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
const preserveLegacyCombinedArchive = async (outputDir: string): Promise<void> => {
const legacyIndex = join(outputDir, 'index.html')
const legacyData = join(outputDir, 'data', 'messages.js')
if (!(await pathExists(legacyIndex)) || !(await pathExists(legacyData))) return
let legacyDir = join(outputDir, '旧版合并档案')
if (await pathExists(legacyDir)) {
legacyDir = join(outputDir, `_${exportStamp()}`)
}
await fs.mkdir(legacyDir, { recursive: true })
for (const entry of ['index.html', 'data', 'avatars', 'media', 'voices', 'files']) {
const source = join(outputDir, entry)
if (await pathExists(source)) await fs.rename(source, join(legacyDir, entry))
}
}
async function runSingleExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
options: SingleExportOptions = {}
): Promise<ExportResult> {
jobs.add(request.jobId)
const send = (p: ExportJobProgress): void => {
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
}
const manageJob = options.manageJob !== false
if (manageJob) jobs.add(request.jobId)
const send =
options.sendProgress ||
((p: ExportJobProgress): void => {
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
})
try {
const targets = request.targets || []
if (targets.length < 1 || targets.length > 5) {
throw new Error('一次导出必须选择 1 到 5 个聊天')
const targets = (request.targets || []).map((target) => ({
...target,
nameMap: { ...(target.nameMap || {}) },
avatarUrls: { ...(target.avatarUrls || {}) }
}))
const targetLimit = request.scope === 'all' ? null : 5
if (targets.length < 1 || (targetLimit !== null && targets.length > targetLimit)) {
throw new Error(
targetLimit === null ? '全部导出至少需要一个聊天' : '一次导出必须选择 1 到 5 个聊天'
)
}
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
throw new Error('导出聊天不能重复')
@@ -652,6 +792,25 @@ export async function runExport(
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
return { success: false, error: '已取消' }
}
if (
target.type === 'group' &&
(request.scope === 'all' || !Object.keys(target.nameMap || {}).length)
) {
const snapshot = await chat.getGroupSnapshotAsync(target.userMd5)
for (const member of snapshot?.members || []) {
target.nameMap![member.wxid] = resolveMemberName(
{
wxid: member.wxid,
nickname: member.nickname,
groupNickname: member.groupNickname,
wechatNickname: member.wechatNickname,
remark: member.remark
},
target.nameMode || 'groupNickname'
)
if (member.avatar) target.avatarUrls![member.wxid] = member.avatar
}
}
const targetMessages = (
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
).filter((message) => request.kinds.includes(kindOf(message)))
@@ -682,8 +841,12 @@ export async function runExport(
return left.messageOrder - right.messageOrder
})
.map((entry) => entry.message)
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
const selfInfo =
options.selfInfo !== undefined
? options.selfInfo
: await chat
.getSelfAccountInfoAsync()
.then((value) => (value ? mergeCachedSelfInfo(value.accountRoot, value) : null))
const client = chat.getChatDb()?.getWcdb4Client()
const isUsableSelfName = (value: string | undefined): value is string => {
const name = String(value || '').trim()
@@ -706,6 +869,7 @@ export async function runExport(
message.exportMediaUrl = undefined
message.exportMediaType = undefined
message.exportMediaName = undefined
message.exportMediaQuality = undefined
message.exportMediaError = undefined
message.voiceDataUrl = undefined
message.voiceTranscript = undefined
@@ -756,25 +920,37 @@ export async function runExport(
total: messages.length,
percent: request.format === 'html' ? 18 : 20
})
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
await fs.mkdir(root, { recursive: true })
const ext = request.format === 'markdown' ? 'md' : request.format
const outputFolder =
request.format === 'html'
? safeFilePart(request.outputName)
? options.outputFolderName || safeFilePart(request.outputName)
: `${safeFilePart(request.outputName)}_${exportStamp()}`
const root = options.outputRoot || (await resolveDefaultExportRoot(outputFolder))
await fs.mkdir(root, { recursive: true })
const outputDir = join(root, outputFolder)
const outputPath =
request.format === 'html'
? join(outputDir, 'index.html')
: join(root, `${outputFolder}.${ext}`)
if (request.format === 'html') {
const previousArchive = await readHtmlArchive(outputDir, targets, archiveName)
const previousArchive = await readHtmlArchive(
outputDir,
targets,
archiveName,
request.scope === 'all'
)
const currentTargetIds = new Set(targets.map((target) => target.userMd5))
const previousMessages =
request.scope === 'all'
? previousArchive.messages.filter((message) =>
currentTargetIds.has(message.exportConversationId || targets[0].userMd5)
)
: previousArchive.messages
const previousMessagesByKey = new Map(
previousArchive.messages.map((message) => [exportMessageKey(message), message])
previousMessages.map((message) => [exportMessageKey(message), message])
)
const latestPreviousAvatarUrls = new Map<string, string>()
for (const message of previousArchive.messages) {
for (const message of previousMessages) {
if (!message.exportAvatarUrl) continue
const conversationId = message.exportConversationId || targets[0].userMd5
latestPreviousAvatarUrls.set(
@@ -1063,15 +1239,20 @@ export async function runExport(
: message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file'
: null
const reusableImageQuality =
previous?.exportMediaQuality === 'original' ||
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
if (
reusableMediaType &&
previous?.exportMediaUrl &&
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
(reusableMediaType !== 'image' || reusableImageQuality) &&
(await resourceExists(previous.exportMediaUrl))
) {
message.exportMediaUrl = previous.exportMediaUrl
message.exportMediaType = reusableMediaType
message.exportMediaName = previous.exportMediaName
message.exportMediaQuality = previous.exportMediaQuality
send({
jobId: request.jobId,
phase: 'media',
@@ -1087,7 +1268,6 @@ export async function runExport(
} else {
let fileFound = false
let decryptedImage: { data: string; filePath: string } | null = null
let usedFallback = false
for (const attempt of getImageExportAttempts(request)) {
const file = await imageService.findImageFileAsync(
message.contentData.md5,
@@ -1116,7 +1296,6 @@ export async function runExport(
}
if (!decrypted) continue
decryptedImage = decrypted
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
break
}
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
@@ -1129,7 +1308,8 @@ export async function runExport(
}
message.exportMediaUrl = mediaUrl
message.exportMediaType = 'image'
if (usedFallback) {
message.exportMediaQuality = imageFileQuality(decryptedImage!.filePath)
if (request.preferOriginal !== false && message.exportMediaQuality === 'thumbnail') {
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
}
} else if (!fileFound) {
@@ -1152,11 +1332,10 @@ export async function runExport(
].filter((value): value is string => Boolean(value))
if (!videoService) {
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
} else if (hashes.length === 0) {
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
} else {
const resolved = await videoService.resolve(hashes, {
createTime: message.createTime,
byteLength: message.contentData.byteLength,
duration: message.contentData.duration,
width: message.contentData.width,
height: message.contentData.height
@@ -1226,7 +1405,7 @@ export async function runExport(
})
}
const mergedMessages = mergeHtmlArchiveMessages(
previousArchive.messages,
previousMessages,
messages,
'',
targets.map((target) => target.userMd5)
@@ -1313,10 +1492,194 @@ export async function runExport(
}
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
return { success: false, error: message }
} finally {
if (manageJob) jobs.delete(request.jobId)
}
}
async function runAllExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
): Promise<ExportResult> {
jobs.add(request.jobId)
const send = (progress: ExportJobProgress): void => {
if (!win.isDestroyed()) win.webContents.send('export:progress', progress)
}
let outputDir = ''
const manifest: AllExportManifestEntry[] = []
let totalMessages = 0
try {
const targets = [...(request.targets || [])].sort((left, right) =>
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
)
if (!targets.length) throw new Error('全部导出至少需要一个聊天')
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
throw new Error('导出聊天不能重复')
}
const outputFolder = safeFilePart(request.outputName)
const exportRoot = await resolveDefaultExportRoot(outputFolder)
outputDir = join(exportRoot, outputFolder)
const folderNames = conversationFolderNames(targets)
let lastProgressAt = 0
let lastProgressKey = ''
await fs.mkdir(outputDir, { recursive: true })
await preserveLegacyCombinedArchive(outputDir)
const selectedTypes = request.allContactTypes?.length
? request.allContactTypes
: Array.from(new Set(targets.map((target) => target.type)))
for (const type of selectedTypes) {
await fs.mkdir(join(outputDir, type === 'group' ? '群聊' : '联系人'), { recursive: true })
}
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
for (const [targetIndex, target] of targets.entries()) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
const categoryName = target.type === 'group' ? '群聊' : '联系人'
const categoryDir = join(outputDir, categoryName)
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
await fs.mkdir(categoryDir, { recursive: true })
const conversationOutputRoot =
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
const basePercent = Math.floor((targetIndex / targets.length) * 100)
send({
jobId: request.jobId,
phase: 'reading',
processed: 0,
percent: basePercent,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
const result = await runSingleExport(
{
...request,
targets: [target],
outputName: target.name,
zip: false
},
win,
voiceRecognition,
{
outputRoot: conversationOutputRoot,
outputFolderName: request.format === 'html' ? folderName : undefined,
manageJob: false,
selfInfo,
sendProgress: (childProgress) => {
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
const percent = Math.min(
99,
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
)
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
const now = Date.now()
const progressKey = `${targetIndex}:${phase}:${percent}`
const terminal = phase === 'failed' || phase === 'cancelled'
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
lastProgressKey = progressKey
lastProgressAt = now
send({
...childProgress,
jobId: request.jobId,
phase,
percent,
outputPath: undefined,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
}
}
)
if (!result.success) {
if (result.error === '已取消') throw new Error('已取消')
throw new Error(`${target.name}${result.error || '导出失败'}`)
}
const messageCount = result.messageCount || 0
totalMessages += messageCount
manifest.push({
id: target.userMd5,
name: target.name,
type: target.type,
folder: `${categoryName}/${folderName}`,
messageCount
})
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
}
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
let completedPath = outputDir
if (request.zip) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
const zipPath = join(exportRoot, `${outputFolder}.zip`)
send({
jobId: request.jobId,
phase: 'compressing',
processed: totalMessages,
total: totalMessages,
percent: 99
})
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
completedPath = zipPath
}
send({
jobId: request.jobId,
phase: 'completed',
processed: totalMessages,
total: totalMessages,
percent: 100,
outputPath: completedPath,
currentTargetIndex: targets.length,
currentTargetCount: targets.length,
currentTargetName: targets.at(-1)?.name,
currentTargetType: targets.at(-1)?.type
})
return { success: true, outputPath: completedPath, messageCount: totalMessages }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const cancelled = !jobs.has(request.jobId) || message === '已取消'
if (outputDir) {
try {
await writeAllExportManifest(
outputDir,
manifest,
totalMessages,
cancelled ? 'cancelled' : 'failed',
cancelled ? undefined : message
)
} catch (manifestError) {
console.warn('[Export] failed to update all-export manifest:', manifestError)
}
}
if (cancelled) {
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, error: '已取消' })
return { success: false, error: '已取消' }
}
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
return { success: false, error: message }
} finally {
jobs.delete(request.jobId)
}
}
export async function runExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
): Promise<ExportResult> {
return request.scope === 'all'
? runAllExport(request, win, voiceRecognition)
: runSingleExport(request, win, voiceRecognition)
}
export function cancelExport(jobId: string): void {
jobs.delete(jobId)
activeArchives.get(jobId)?.abort()
+34 -10
View File
@@ -7,7 +7,8 @@ import {
GroupReportExportResult,
GroupReportMetadata,
ReportHeat,
ReportSectionMeta
ReportSectionMeta,
selectHeroParticipantNames
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
import { imageInsightService } from './services/image-insight-service'
@@ -74,7 +75,7 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, {
headers: {
'User-Agent': 'Mozilla/5.0 WechatExplorer',
'User-Agent': 'Mozilla/5.0 TraceMemo',
Referer: 'https://weixin.qq.com/'
},
signal: AbortSignal.timeout(8000)
@@ -186,11 +187,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
const heroNames = metadata.heroParticipants.slice(0, 4)
while (heroNames.length < 4) heroNames.push(metadata.groupName)
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
const heroAvatars = heroNames
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
.join('')
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
const topicCards = report.topics
.map(
@@ -218,10 +219,18 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
if (insight) {
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
// 走 ImageDecryptService.findImageFile + decryptImageToBase64
const decryptService = (globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null; decryptImageToBase64: (p: string) => string | null } }).__imageDecrypt
const decryptService = (
globalThis as {
__imageDecrypt?: {
findImageFile: (md5?: string, dat?: string) => string | null
decryptImageToBase64: (p: string) => string | null
}
}
).__imageDecrypt
if (decryptService) {
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
if (filePath)
imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
}
}
}
@@ -451,7 +460,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(metadata.recordNote),
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
OVERVIEW: escapeHtml(
report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'
),
// v2 模板使用的 hero-*
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
@@ -462,6 +473,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
HERO_AVATAR_CLASS: heroAvatarClass,
MESSAGE_COUNT: String(summaryStats.messageCount),
ACTIVE_USERS: String(summaryStats.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
@@ -473,13 +485,21 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
RESOURCE_ITEMS: resourceItems,
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
MESSAGES_EMPTY_CLASS: sectionClass(
request,
'importantMessages',
report.importantMessages.length > 0
),
IMPORTANT_MESSAGES: importantMessages,
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
QUOTE_BLOCKS: quoteBlocks,
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
ACTIONS_EMPTY_CLASS: sectionClass(
request,
'actions',
report.todos.length + report.unresolved.length > 0
),
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
TODO_CARDS: todoCards,
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
@@ -511,7 +531,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
VOICE_RANK_EMPTY_CLASS: sectionClass(
request,
'voices',
report.analytics.voiceLeaderboard?.length > 0
),
VOICE_RANK_CARDS: voiceRankCards,
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
BADGE_CARDS: badgeCards,
+9 -9
View File
@@ -167,7 +167,7 @@ const routes: Record<string, RouteHandler> = {
sendJson(res, 200, {
ok: true,
ready: isReady(),
service: 'WechatExplorer Reader',
service: 'TraceMemo Reader',
version: '1.0.0',
timestamp: new Date().toISOString()
})
@@ -186,7 +186,7 @@ const routes: Record<string, RouteHandler> = {
},
'/api/v1/contact': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const filter = url.searchParams.get('filter') || undefined
const type = url.searchParams.get('type') || undefined
let contacts = listContacts(filter)
@@ -197,7 +197,7 @@ const routes: Record<string, RouteHandler> = {
},
'/api/v1/chatroom': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const keyword = url.searchParams.get('keyword') || ''
let groups = listContacts().filter((c) => c.type === 'group')
if (keyword) {
@@ -212,14 +212,14 @@ const routes: Record<string, RouteHandler> = {
},
'/api/v1/recent_chat': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const limit = parseNumeric(url.searchParams.get('limit'), 50)
const items = listRecentChat(limit)
sendJson(res, 200, { count: items.length, items })
},
'/api/v1/chatlog': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const talker = url.searchParams.get('talker')
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
@@ -257,7 +257,7 @@ const routes: Record<string, RouteHandler> = {
},
'/api/v1/group_snapshot': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const md5 = url.searchParams.get('md5')
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
const snapshot = getGroupSnapshot(md5)
@@ -266,7 +266,7 @@ const routes: Record<string, RouteHandler> = {
},
'/api/v1/resolve': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const q = url.searchParams.get('q')
if (!q) return sendError(res, 400, '缺少必要参数 q')
const contact = resolveMd5(q)
@@ -276,7 +276,7 @@ const routes: Record<string, RouteHandler> = {
'/api/v1/report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
if (typeof body !== 'string' || !body.trim()) {
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
}
@@ -300,7 +300,7 @@ const routes: Record<string, RouteHandler> = {
'/api/v1/agent/group-report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
+34 -20
View File
@@ -7,10 +7,13 @@ import { execFile } from 'child_process'
import { Worker } from 'worker_threads'
import ffmpegStaticPath from 'ffmpeg-static'
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
import { loadSettings } from './services/settings-store'
import { Wcdb4Client } from './wcdb4-client'
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
const imageDecryptDebugEnabled =
process.env['TRACEMEMO_DEBUG_IMAGE'] === '1' ||
(!process.env['TRACEMEMO_DEBUG_IMAGE'] && process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1')
const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args)
}
@@ -208,6 +211,13 @@ function isThumbnailName(fileName) {
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
}
function imageQualityRank(fileName) {
const lower = fileName.toLowerCase()
if (/(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)) return 1
if (/(?:_hd|\.hd|_h_m|_h|\.h)\.dat$/i.test(lower)) return 3
return 2
}
function buildPreferredDatNames(baseName) {
const base = normalizeDatBase(baseName)
if (!base) return []
@@ -436,8 +446,8 @@ function collectCandidates(datPath, allowThumbnail) {
.map((name) => path.join(directory, name))
.filter((candidate) => fs.existsSync(candidate))
.sort((left, right) => {
const thumbnailOrder = Number(isThumbnailName(path.basename(left))) - Number(isThumbnailName(path.basename(right)))
return thumbnailOrder || fs.statSync(right).size - fs.statSync(left).size
const qualityOrder = imageQualityRank(path.basename(right)) - imageQualityRank(path.basename(left))
return qualityOrder || fs.statSync(right).size - fs.statSync(left).size
})
return Array.from(new Set(candidates.concat(siblings)))
}
@@ -554,7 +564,7 @@ export class ImageDecryptService {
}
/**
* 根据 md5 查找图片文件 (WechatExplorer 风格)
* 根据 md5 查找图片文件
*/
findImageFile(md5?: string, imageDatName?: string, options?: ImageFindOptions): string | null {
const allowThumbnail = options?.allowThumbnail !== false
@@ -640,7 +650,7 @@ export class ImageDecryptService {
}
}
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
// 尝试 TraceMemo 兼容的微信目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
if (!existsSync(attachDir)) {
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return rememberPath(
@@ -1824,17 +1834,17 @@ export class ImageDecryptService {
.sort((left, right) => right.size - left.size)
const thumbnail = toSized(
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
paths.filter((candidate) => imageFileQuality(candidate) === 'thumbnail')
)
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
const nonThumb = toSized(
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
)
if (nonThumb[0]) return nonThumb[0].candidate
if (!allowThumbnail) return null
const existing = toSized(paths)
return existing[0]?.candidate || null
const allowed = toSized(paths)
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
.sort(
(left, right) =>
imageQualityRank(imageFileQuality(right.candidate)) -
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
)
return allowed[0]?.candidate || null
}
private async getLargestExistingPathAsync(
@@ -1858,17 +1868,21 @@ export class ImageDecryptService {
.sort((left, right) => right.size - left.size)
if (preferThumbnail) {
const thumbnail = sized.find((entry) => this.isThumbnailName(basename(entry.candidate)))
const thumbnail = sized.find((entry) => imageFileQuality(entry.candidate) === 'thumbnail')
if (thumbnail) return thumbnail.candidate
}
const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate)))
if (nonThumbnail) return nonThumbnail.candidate
return allowThumbnail ? sized[0]?.candidate || null : null
const allowed = sized
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
.sort(
(left, right) =>
imageQualityRank(imageFileQuality(right.candidate)) -
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
)
return allowed[0]?.candidate || null
}
private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase()
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
return imageFileQuality(fileName) === 'thumbnail'
}
isThumbnailFile(filePath: string): boolean {
+160 -29
View File
@@ -1,3 +1,8 @@
import {
isLegacyMigrationHelper,
isUserDataIsolated,
roots as appDataRoots
} from './app-data-bootstrap'
import './preload-env'
import {
app,
@@ -97,7 +102,7 @@ import { agentHubService } from './services/agent-hub-service'
import { appLogger } from './app-logger'
import type { AppLogEntry } from '../shared/app-log'
import { appUpdateService } from './services/app-update-service'
import { clearCache, getCacheSummary } from './services/cache-service'
import { clearCache, getCacheSummary, openKnowledgeDirectory } from './services/cache-service'
import type { CacheClearScope } from './services/cache-service'
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
import { VideoAssetService } from './video-asset-service'
@@ -109,8 +114,14 @@ import { VoiceBatchService } from './voice-pipeline/voice-batch-service'
import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition'
import type { AiSearchPipelineRequest } from '../shared/ai-search'
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
import {
isWindowsVcRuntimeMissingError,
WINDOWS_VC_RUNTIME_ERROR_MESSAGE
} from '../shared/windows-runtime'
import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
import { runFirstLaunchMigration } from './app-data-migration'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -268,20 +279,10 @@ protocol.registerSchemesAsPrivileged([
}
])
// WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
app.setName(
process.platform === 'win32'
? 'WeFlow'
: process.env['WXE_USER_DATA']
? 'WechatExplorer Dev'
: 'WechatExplorer'
)
const isolatedUserData = process.env['WXE_USER_DATA']
if (isolatedUserData) app.setPath('userData', isolatedUserData)
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
null
let appShutdownRequested = false
let isQuitting = false
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
@@ -416,11 +417,48 @@ function createWindow(): void {
sandbox: false
}
})
let closePromptInFlight = false
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.on('close', (event) => {
if (isQuitting || appShutdownRequested) return
event.preventDefault()
if (closePromptInFlight) return
closePromptInFlight = true
void dialog
.showMessageBox(mainWindow, {
type: 'question',
title: '关闭 TraceMemo',
message: '请选择关闭方式',
detail: '你可以将窗口隐藏到系统托盘,或退出整个应用进程。',
buttons: ['最小化到系统托盘', '关闭进程', '取消'],
defaultId: 0,
cancelId: 2,
noLink: true
})
.then(({ response }) => {
if (response === 0) {
setupTray()
mainWindow.hide()
if (process.platform === 'darwin') app.dock?.hide()
return
}
if (response === 1) {
isQuitting = true
app.quit()
}
})
.catch((error) => {
console.warn('[Window] close prompt failed:', error)
})
.finally(() => {
closePromptInFlight = false
})
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
@@ -438,6 +476,74 @@ function createWindow(): void {
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
// 某些 API 只能在此事件发生后使用
app.whenReady().then(async () => {
if (isLegacyMigrationHelper) {
try {
runLegacySafeStorageHelper()
app.exit(0)
} catch {
app.exit(1)
}
return
}
try {
const migration = isUserDataIsolated ? null : await runFirstLaunchMigration(appDataRoots)
apiTokenStore.setAutomaticGenerationBlocked(migration?.tokenBlockReason)
if (!migration) {
appLogger.write({
level: 'info',
scope: 'app-data-migration',
message: '隔离 userData 已启用,跳过真实用户数据迁移'
})
} else {
appLogger.write({
level: migration.assessment.selection.legacyConflict ? 'warn' : 'info',
scope: 'app-data-migration',
message: migration.assessment.selection.legacyConflict
? '检测到两个独立的 legacy userData,已按兼容优先级选择 WechatExplorer 作为迁移源'
: 'TraceMemo 数据身份检查完成',
details: {
action: migration.action,
reason: migration.assessment.reason,
sourceRoot: migration.assessment.sourceRoot,
targetRoot: appDataRoots.current,
legacyExists: migration.assessment.selection.directories.legacy,
legacyPackageExists: migration.assessment.selection.directories.legacyPackage,
legacyAssets: migration.assessment.selection.assets.legacy,
legacyPackageAssets: migration.assessment.selection.assets.legacyPackage,
legacyRootsEquivalent: migration.assessment.selection.legacyRootsEquivalent,
legacyConflict: migration.assessment.selection.legacyConflict,
migrationStatus: migration.execution?.state.status,
tokenGenerationBlocked: migration.tokenGenerationBlocked
}
})
}
} catch (error) {
const targetToken = join(appDataRoots.current, 'local-api-token.bin')
const legacyTokenExists = [appDataRoots.legacy, appDataRoots.legacyPackage].some((root) =>
existsSync(join(root, 'local-api-token.bin'))
)
if (!existsSync(targetToken) && legacyTokenExists) {
apiTokenStore.setAutomaticGenerationBlocked(
'旧版 API Token 迁移未完成,本地 API 已安全停用。请保留旧数据并重新启动迁移。'
)
}
appLogger.write({
level: 'error',
scope: 'app-data-migration',
message: 'TraceMemo 数据迁移初始化失败',
details: { error: error instanceof Error ? error.message : String(error) }
})
await dialog.showMessageBox({
type: 'warning',
title: 'TraceMemo 数据迁移',
message: '旧数据迁移未能启动',
detail:
'旧目录没有被修改或删除。请保留 WechatExplorer 数据并重新启动 TraceMemo;旧 API Token 不会被静默替换。',
buttons: ['好']
})
}
voiceRecognition = new VoiceRecognitionUseCase({
modelRoot: join(app.getPath('userData'), 'models', 'sensevoice-small-int8'),
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
@@ -474,11 +580,11 @@ app.whenReady().then(async () => {
return new Response('Media unavailable', { status: 500 })
}
})
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
console.log(`TraceMemo main build: ${BUILD_MARK}`)
appLogger.write({
level: 'info',
scope: 'lifecycle',
message: 'WechatExplorer 启动',
message: 'TraceMemo 启动',
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
})
process.on('uncaughtException', (error) => {
@@ -506,9 +612,16 @@ app.whenReady().then(async () => {
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
console.log('[WCDB4] async bootstrap complete')
})
void wcdbBootstrapPromise.catch((error) => {
appLogger.write({
level: 'error',
scope: 'wcdb-bootstrap',
message: error instanceof Error ? error.message : String(error)
})
})
// 设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.wechatexplorer.app')
electronApp.setAppUserModelId('com.tracememo.app')
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
@@ -529,6 +642,7 @@ app.whenReady().then(async () => {
ipcMain.handle('app-update:download', () => appUpdateService.download())
ipcMain.handle('app-update:install', () => appUpdateService.install())
ipcMain.handle('cache:getSummary', () => getCacheSummary())
ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory())
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'knowledge', 'all']
if (!allowedScopes.includes(scope)) return getCacheSummary()
@@ -625,7 +739,16 @@ app.whenReady().then(async () => {
return { success: true, monitoring }
} catch (error) {
console.error('Failed to init DB:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
const detail = error instanceof Error ? error.message : String(error)
if (isWindowsVcRuntimeMissingError(detail, process.platform)) {
return {
success: false,
code: 'VC_RUNTIME_MISSING',
error: WINDOWS_VC_RUNTIME_ERROR_MESSAGE,
monitoring: false
}
}
return { success: false, error: detail }
} finally {
dbInitInFlight = null
}
@@ -1093,6 +1216,10 @@ app.whenReady().then(async () => {
return voiceRecognition.recognize(reference)
})
ipcMain.handle('voice:getTranscriptSnapshot', (_, reference: VoiceMessageReference) => {
return voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' as const }
})
ipcMain.handle('voice:getBatchPreflight', (_, request: VoiceBatchRequest) => {
if (!voiceBatchService) throw new Error('Voice recognition is not initialized')
return voiceBatchService.preflight(request)
@@ -1339,7 +1466,13 @@ app.whenReady().then(async () => {
async (
_,
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
options?: {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
}
) => {
if (!videoAssetService) {
const client = chat.getChatDb()?.getWcdb4Client()
@@ -1541,10 +1674,8 @@ app.whenReady().then(async () => {
await agentHubService.start(settings)
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
}
setupTray()
if (TRAY_MODE) app.dock?.hide()
app.on('activate', function () {
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
@@ -1568,6 +1699,7 @@ let quitCleanupComplete = false
app.on('before-quit', (event) => {
if (quitCleanupComplete) return
isQuitting = true
appShutdownRequested = true
event.preventDefault()
if (quitCleanupStarted) return
@@ -1607,7 +1739,7 @@ app.on('before-quit', (event) => {
})
function showMainWindow(): void {
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
if (process.platform === 'darwin') app.dock?.show().catch(() => undefined)
const wins = BrowserWindow.getAllWindows()
if (wins.length === 0) {
createWindow()
@@ -1625,13 +1757,9 @@ function buildTrayMenu(): Menu {
label: '打开主窗口',
click: () => showMainWindow()
},
{
label: 'API 状态',
click: () => showMainWindow()
},
{ type: 'separator' },
{
label: '退出 WechatExplorer',
label: '退出 TraceMemo',
click: () => {
tray?.destroy()
tray = null
@@ -1650,9 +1778,12 @@ function setupTray(): void {
? nativeImage.createEmpty()
: image.resize({ width: traySize, height: traySize, quality: 'best' })
tray = new Tray(trayImage)
tray.setToolTip('WechatExplorer')
tray.setContextMenu(buildTrayMenu())
tray.setToolTip('TraceMemo')
// macOS may show a Tray context menu on a primary click when it is set
// directly on the Tray. Keep the menu for an explicit secondary click so
// the primary click only restores the main window.
tray.on('click', () => showMainWindow())
tray.on('right-click', () => tray?.popUpContextMenu(buildTrayMenu()))
} catch (error) {
console.warn('[Tray] Failed to create tray:', error)
}
+206 -356
View File
@@ -1,9 +1,6 @@
// @ts-nocheck
// Windows native bridge adapted from WeFlow. Koffi Win32 callbacks and optional
// helpers are intentionally dynamic; keep this file isolated from strict TS.
import { join, dirname, delimiter } from 'path'
import { existsSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process'
import { execFile } from 'child_process'
import { promisify } from 'util'
import os from 'os'
import crypto from 'crypto'
@@ -12,14 +9,19 @@ import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
const execFileAsync = promisify(execFile)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
type ImageKeyResult = {
success: boolean
xorKey?: number
aesKey?: string
verified?: boolean
error?: string
}
type DbKeyPollResult =
| { status: 'success'; key: string; loginRequiredDetected: boolean }
| { status: 'process-ended'; loginRequiredDetected: boolean }
| { status: 'timeout'; loginRequiredDetected: boolean }
export class KeyService {
private readonly isMac = process.platform === 'darwin'
private koffi: any = null
private lib: any = null
private initialized = false
@@ -28,19 +30,15 @@ export class KeyService {
private getStatusMessage: any = null
private cleanupHook: any = null
private getLastErrorMsg: any = null
private getImageKeyDll: any = null
private lastLoadError = ''
// Win32 APIs
private kernel32: any = null
private user32: any = null
private advapi32: any = null
// Kernel32
private OpenProcess: any = null
private CloseHandle: any = null
private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
// User32
private EnumWindows: any = null
@@ -50,22 +48,9 @@ export class KeyService {
private GetWindowThreadProcessId: any = null
private IsWindowVisible: any = null
private EnumChildWindows: any = null
private PostMessageW: any = null
private WNDENUMPROC_PTR: any = null
// Advapi32
private RegOpenKeyExW: any = null
private RegQueryValueExW: any = null
private RegCloseKey: any = null
// Constants
private readonly PROCESS_ALL_ACCESS = 0x1F0FFF
private readonly PROCESS_TERMINATE = 0x0001
private readonly KEY_READ = 0x20019
private readonly HKEY_LOCAL_MACHINE = 0x80000002
private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0
private readonly WM_CLOSE = 0x0010
private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000
private getResourceRoots(): string[] {
@@ -156,11 +141,11 @@ export class KeyService {
this.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)')
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
this.getStatusMessage = this.lib.func(
'bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)'
)
this.cleanupHook = this.lib.func('bool CleanupHook()')
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)')
this.initialized = true
return true
} catch (e) {
@@ -186,8 +171,6 @@ export class KeyService {
this.kernel32 = this.koffi.load('kernel32.dll')
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
return true
} catch (e) {
@@ -211,12 +194,26 @@ export class KeyService {
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', [
'void*',
this.WNDENUMPROC_PTR,
'intptr_t'
])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', [
'void*',
this.koffi.out('uint16*'),
'int'
])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')])
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', [
'void*',
this.koffi.out('uint16*'),
'int'
])
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', [
'void*',
this.koffi.out('uint32*')
])
this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*'])
return true
@@ -226,26 +223,6 @@ export class KeyService {
}
}
private ensureAdvapi32(): boolean {
if (this.advapi32) return true
try {
this.koffi = require('koffi')
this.advapi32 = this.koffi.load('advapi32.dll')
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
const HKEY_PTR = this.koffi.pointer(HKEY)
this.RegOpenKeyExW = this.advapi32.func('RegOpenKeyExW', 'long', [HKEY, 'uint16*', 'uint32', 'uint32', this.koffi.out(HKEY_PTR)])
this.RegQueryValueExW = this.advapi32.func('RegQueryValueExW', 'long', [HKEY, 'uint16*', 'uint32*', this.koffi.out('uint32*'), this.koffi.out('uint8*'), this.koffi.out('uint32*')])
this.RegCloseKey = this.advapi32.func('RegCloseKey', 'long', [HKEY])
return true
} catch (e) {
console.error('初始化 advapi32 失败:', e)
return false
}
}
private decodeCString(ptr: any): string {
try {
if (typeof ptr === 'string') return ptr
@@ -255,123 +232,19 @@ export class KeyService {
}
}
// --- WeChat Process & Path Finding ---
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
if (!this.ensureAdvapi32()) return null
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
const phkResult = Buffer.alloc(8)
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) return null
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
try {
const lpcbData = Buffer.alloc(4)
lpcbData.writeUInt32LE(0, 0)
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
const size = lpcbData.readUInt32LE(0)
if (size === 0) return null
const dataBuf = Buffer.alloc(size)
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
let str = dataBuf.toString('ucs2')
if (str.endsWith('\0')) str = str.slice(0, -1)
return str
} finally {
this.RegCloseKey(hKey)
}
}
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
try {
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32LE(1024, 0)
const pathBuf = Buffer.alloc(1024 * 2)
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
if (ret) {
const len = sizeBuf.readUInt32LE(0)
return pathBuf.toString('ucs2', 0, len * 2)
}
return null
} catch (e) {
console.error('获取进程路径失败:', e)
return null
} finally {
this.CloseHandle(hProcess)
}
}
private async findWeChatInstallPath(): Promise<string | null> {
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) return runPath
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
]
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
const tencentKeys = [
'Software\\Tencent\\WeChat',
'Software\\WOW6432Node\\Tencent\\WeChat',
'Software\\Tencent\\Weixin',
]
for (const root of roots) {
for (const key of tencentKeys) {
const path = this.readRegistryString(root, key, 'InstallPath')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
if (path && existsSync(join(path, 'WeChat.exe'))) return join(path, 'WeChat.exe')
}
}
for (const root of roots) {
for (const parent of uninstallKeys) {
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
}
}
const drives = ['C', 'D', 'E', 'F']
const commonPaths = [
'Program Files\\Tencent\\WeChat\\WeChat.exe',
'Program Files (x86)\\Tencent\\WeChat\\WeChat.exe',
'Program Files\\Tencent\\Weixin\\Weixin.exe',
'Program Files (x86)\\Tencent\\Weixin\\Weixin.exe'
]
for (const drive of drives) {
for (const p of commonPaths) {
const full = join(drive + ':\\', p)
if (existsSync(full)) return full
}
}
return null
}
private async findPidsByImageName(imageName: string): Promise<number[]> {
try {
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
const { stdout } = await execFileAsync('tasklist', [
'/FI',
`IMAGENAME eq ${imageName}`,
'/FO',
'CSV',
'/NH'
])
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const pids: number[] = []
for (const line of lines) {
if (line.startsWith('INFO:')) continue
@@ -418,7 +291,7 @@ export class KeyService {
const fallbackPid = await this.waitForWeChatWindow(250)
if (fallbackPid) return fallbackPid
await new Promise(r => setTimeout(r, 500))
await new Promise((r) => setTimeout(r, 500))
}
return null
}
@@ -428,10 +301,10 @@ export class KeyService {
}
private async pollDbKeyFromHook(
pid: number,
deadline: number,
logs: string[],
onStatus?: (message: string, level: number) => void
pid: number,
deadline: number,
logs: string[],
onStatus?: (message: string, level: number) => void
): Promise<DbKeyPollResult> {
const keyBuffer = Buffer.alloc(128)
let loginRequiredDetected = false
@@ -441,7 +314,7 @@ export class KeyService {
const now = Date.now()
if (now >= nextProcessCheckAt) {
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
if (!await this.isWeChatPidActive(pid)) {
if (!(await this.isWeChatPidActive(pid))) {
return { status: 'process-ended', loginRequiredDetected }
}
}
@@ -477,13 +350,17 @@ export class KeyService {
private cleanupDbKeyHook(): void {
try {
this.cleanupHook()
} catch { }
} catch {}
}
private buildInitHookError(): string {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) {
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
if (
error.includes('0xC0000022') ||
error.includes('ACCESS_DENIED') ||
error.includes('打开目标进程失败')
) {
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
}
return error
@@ -491,13 +368,17 @@ export class KeyService {
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
const status =
this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
? this.decodeUtf8(statusBuffer)
: ''
return status || '初始化失败'
}
private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
private async waitForNextDbKeyPid(
deadline: number,
onStatus?: (message: string, level: number) => void
): Promise<number | null> {
while (this.getRemainingMs(deadline) > 0) {
onStatus?.('正在查找微信进程...', 0)
const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000))
@@ -514,17 +395,23 @@ export class KeyService {
await new Promise((resolve) => setTimeout(resolve, 500))
}
private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
private async waitForProcessRestart(
deadline: number,
onStatus?: (message: string, level: number) => void
): Promise<number | null> {
if (!this.shouldRetryAfterProcessLost(deadline)) return null
onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0)
await this.delayBeforeRetry()
return this.waitForNextDbKeyPid(deadline, onStatus)
}
private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise<boolean> {
private async detectLoginRequiredForLastPid(
pid: number | null,
loginRequiredDetected: boolean
): Promise<boolean> {
if (loginRequiredDetected) return true
if (!pid) return false
if (!await this.isWeChatPidActive(pid)) return false
if (!(await this.isWeChatPidActive(pid))) return false
return await this.detectWeChatLoginRequired(pid)
}
@@ -535,56 +422,6 @@ export class KeyService {
return fallbackPid ?? null
}
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const runningPids = await this.findWeChatPids()
if (runningPids.length === 0) return true
await new Promise(r => setTimeout(r, 400))
}
return false
}
private async closeWeChatWindows(): Promise<boolean> {
if (!this.ensureUser32()) return false
let requested = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
const className = this.getClassName(hWnd)
const classLower = (className || '').toLowerCase()
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
if (!isWeChatWindow) return true
requested = true
try {
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
} catch { }
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
return requested
}
private async killWeChatProcesses(): Promise<boolean> {
const requested = await this.closeWeChatWindows()
if (requested) {
const gracefulOk = await this.waitForWeChatExit(1500)
if (gracefulOk) return true
}
try {
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
} catch (e) { }
return await this.waitForWeChatExit(5000)
}
// --- Window Detection ---
private getWindowTitle(hWnd: any): string {
@@ -614,7 +451,7 @@ export class KeyService {
while (Date.now() - startTime < timeoutMs) {
let foundPid: number | null = null
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
@@ -633,14 +470,14 @@ export class KeyService {
this.koffi.unregister(enumWindowsCallback)
if (foundPid) return foundPid
await new Promise(r => setTimeout(r, 500))
await new Promise((r) => setTimeout(r, 500))
}
return null
}
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
const children: Array<{ title: string; className: string }> = []
const enumChildCallback = this.koffi.register((hChild: any, lp: any) => {
const enumChildCallback = this.koffi.register((hChild: any, _lp: any) => {
const title = this.getWindowTitle(hChild).trim()
const className = this.getClassName(hChild).trim()
children.push({ title, className })
@@ -655,7 +492,16 @@ export class KeyService {
if (children.length === 0) return false
const readyTexts = ['聊天', '登录', '账号']
const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', 'BrowserWnd', 'ListView']
const readyClassMarkers = [
'WeChat',
'Weixin',
'TXGuiFoundation',
'Qt5',
'ChatList',
'MainWnd',
'BrowserWnd',
'ListView'
]
const readyChildCountThreshold = 14
let classMatchCount = 0
@@ -665,12 +511,12 @@ export class KeyService {
for (const child of children) {
const normalizedTitle = child.title.replace(/\s+/g, '')
if (normalizedTitle) {
if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true
if (readyTexts.some((marker) => normalizedTitle.includes(marker))) return true
titleMatchCount += 1
}
const className = child.className
if (className) {
if (readyClassMarkers.some(marker => className.includes(marker))) return true
if (readyClassMarkers.some((marker) => className.includes(marker))) return true
if (className.length > 5) {
classMatchCount += 1
hasValidClassName = true
@@ -685,7 +531,9 @@ export class KeyService {
}
private isLoginRelatedText(value: string): boolean {
const normalized = String(value || '').replace(/\s+/g, '').toLowerCase()
const normalized = String(value || '')
.replace(/\s+/g, '')
.toLowerCase()
if (!normalized) return false
const keywords = [
'登录',
@@ -741,7 +589,7 @@ export class KeyService {
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
let ready = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
@@ -763,7 +611,7 @@ export class KeyService {
this.koffi.unregister(enumWindowsCallback)
if (ready) return true
await new Promise(r => setTimeout(r, 500))
await new Promise((r) => setTimeout(r, 500))
}
return true
}
@@ -771,8 +619,8 @@ export class KeyService {
// --- DB Key Logic (core hook/poll flow unchanged) ---
async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: this.getLoadError() }
@@ -794,14 +642,14 @@ export class KeyService {
onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline)))
if (!await this.isWeChatPidActive(pid)) {
if (!(await this.isWeChatPidActive(pid))) {
pid = await this.waitForProcessRestart(deadline, onStatus)
continue
}
const ok = this.initHook(pid)
if (!ok) {
if (!await this.isWeChatPidActive(pid)) {
if (!(await this.isWeChatPidActive(pid))) {
this.cleanupDbKeyHook()
pid = await this.waitForProcessRestart(deadline, onStatus)
continue
@@ -828,7 +676,10 @@ export class KeyService {
break
}
const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected)
const loginRequired = await this.detectLoginRequiredForLastPid(
pid,
lastAttemptLoginRequiredDetected
)
if (loginRequired) {
return {
success: false,
@@ -840,81 +691,10 @@ export class KeyService {
return { success: false, error: '获取密钥超时', logs }
}
private cleanWxid(wxid: string): string {
const first = wxid.indexOf('_')
if (first === -1) return wxid
const second = wxid.indexOf('_', first + 1)
if (second === -1) return wxid
return wxid.substring(0, second)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
return { xorKey, aesKey }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
const candidates: string[] = []
const pushUnique = (value: string) => {
const v = String(value || '').trim()
if (!v || candidates.includes(v)) return
candidates.push(v)
}
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
if (manualDir) {
const normalized = manualDir.replace(/[\\/]+$/, '')
const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName)
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
const marker = normalized.match(/[\\/]xwechat_files/i)
if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length)
try {
const { readdirSync, statSync } = await import('fs')
const { join } = await import('path')
for (const entry of readdirSync(root)) {
if (!entry.startsWith('wxid_')) continue
const full = join(root, entry)
try {
if (statSync(full).isDirectory()) pushUnique(entry)
} catch { }
}
} catch { }
}
}
pushUnique('unknown')
return candidates
}
async autoGetImageKey(
manualDir?: string,
onProgress?: (message: string) => void,
wxidParam?: string
manualDir?: string,
onProgress?: (message: string) => void,
wxidParam?: string
): Promise<ImageKeyResult> {
void wxidParam
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
@@ -935,9 +715,16 @@ export class KeyService {
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result
const firstDiag = (this as { _imageTemplateDiag?: {
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
} })._imageTemplateDiag
const firstDiag = (
this as {
_imageTemplateDiag?: {
userDir: string
totalTFiles: number
v2Count: number
nonV2Count: number
}
}
)._imageTemplateDiag
// 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) {
@@ -948,9 +735,17 @@ export class KeyService {
if (!ciphertext) {
// 用诊断信息给具体提示
const diag = (this as { _imageTemplateDiag?: {
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
} })._imageTemplateDiag || firstDiag
const diag =
(
this as {
_imageTemplateDiag?: {
userDir: string
totalTFiles: number
v2Count: number
nonV2Count: number
}
}
)._imageTemplateDiag || firstDiag
if (!diag || diag.totalTFiles === 0) {
return {
success: false,
@@ -978,7 +773,11 @@ export class KeyService {
'请在微信中查看更多图片后再试。'
}
}
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
if (xorKey === null)
return {
success: false,
error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片'
}
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
@@ -1000,7 +799,7 @@ export class KeyService {
return { success: true, xorKey, aesKey }
}
// 等 5 秒再试
await new Promise(r => setTimeout(r, 5000))
await new Promise((r) => setTimeout(r, 5000))
}
return {
@@ -1012,7 +811,10 @@ export class KeyService {
}
}
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
private async _findTemplateData(
userDir: string,
limit: number = 32
): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const { readdirSync, readFileSync, statSync } = await import('fs')
const { join } = await import('path')
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
@@ -1027,7 +829,9 @@ export class KeyService {
if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
}
} catch { /* 忽略无权限目录 */ }
} catch {
/* 忽略无权限目录 */
}
}
const files: string[] = []
@@ -1035,7 +839,11 @@ export class KeyService {
// 按修改时间降序
files.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
try {
return statSync(b).mtimeMs - statSync(a).mtimeMs
} catch {
return 0
}
})
let ciphertext: Buffer | null = null
@@ -1058,17 +866,24 @@ export class KeyService {
}
// 提取密文(取第一个有效的)
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
ciphertext = data.subarray(0xF, 0x1F)
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1f) {
ciphertext = data.subarray(0xf, 0x1f)
}
} catch { /* 忽略 */ }
} catch {
/* 忽略 */
}
}
// 计算 XOR 密钥
let xorKey: number | null = null
let maxCount = 0
for (const [key, count] of Object.entries(tailCounts)) {
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
if (count > maxCount) {
maxCount = count
const [x, y] = key.split('_').map(Number)
const k = x ^ 0xff
if (k === (y ^ 0xd9)) xorKey = k
}
}
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
@@ -1091,8 +906,19 @@ export class KeyService {
if (!this.ensureKernel32()) return null
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t'])
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')])
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', [
'void*',
'uintptr',
'void*',
'size_t'
])
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', [
'void*',
'uintptr',
'void*',
'size_t',
this.koffi.out('size_t*')
])
// RW 保护标志(只扫可写区域,速度更快)
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
@@ -1101,7 +927,7 @@ export class KeyService {
const PAGE_GUARD = 0x100
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
const hProcess = this.OpenProcess(0x1F0FFF, false, pid)
const hProcess = this.OpenProcess(0x1f0fff, false, pid)
if (!hProcess) return null
try {
@@ -1110,7 +936,7 @@ export class KeyService {
let addr = 0
const mbi = Buffer.alloc(MBI_SIZE)
while (addr < 0x7FFFFFFFFFFF) {
while (addr < 0x7fffffffffff) {
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
if (ret === 0) break
// MEMORY_BASIC_INFORMATION x64 布局:
@@ -1126,11 +952,13 @@ export class KeyService {
const state = mbi.readUInt32LE(32)
const protect = mbi.readUInt32LE(36)
if (state === MEM_COMMIT &&
protect !== PAGE_NOACCESS &&
(protect & PAGE_GUARD) === 0 &&
(protect & RW_FLAGS) !== 0 &&
size <= 50 * 1024 * 1024) {
if (
state === MEM_COMMIT &&
protect !== PAGE_NOACCESS &&
(protect & PAGE_GUARD) === 0 &&
(protect & RW_FLAGS) !== 0 &&
size <= 50 * 1024 * 1024
) {
regions.push([base, size])
}
const next = base + size
@@ -1148,7 +976,7 @@ export class KeyService {
const [base, size] = regions[i]
if (i % 20 === 0) {
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
await new Promise(r => setTimeout(r, 1)) // 让出事件循环
await new Promise((r) => setTimeout(r, 1)) // 让出事件循环
}
let offset = 0
@@ -1159,17 +987,29 @@ export class KeyService {
const buf = Buffer.alloc(chunkSize)
const bytesReadOut = [0]
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue }
if (!ok || bytesReadOut[0] === 0) {
offset += chunkSize
trailing = null
continue
}
const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0])
const data: Buffer = trailing
? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])])
: buf.subarray(0, bytesReadOut[0])
// 搜索 ASCII 32字节密钥
const key = this._searchAsciiKey(data, ciphertext)
if (key) { this.CloseHandle(hProcess); return key }
if (key) {
this.CloseHandle(hProcess)
return key
}
// 搜索 UTF-16LE 32字节密钥
const key16 = this._searchUtf16Key(data, ciphertext)
if (key16) { this.CloseHandle(hProcess); return key16 }
if (key16) {
this.CloseHandle(hProcess)
return key16
}
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
offset += chunkSize
@@ -1187,12 +1027,16 @@ export class KeyService {
if (this._isAlphaNum(data[i])) continue
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
if (!this._isAlphaNum(data[i + j])) {
valid = false
break
}
}
if (!valid) continue
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
const keyBytes = data.subarray(i + 1, i + 33)
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
if (this._verifyAesKey(keyBytes, ciphertext))
return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
@@ -1201,18 +1045,22 @@ export class KeyService {
for (let i = 0; i < data.length - 65; i++) {
let valid = true
for (let j = 0; j < 32; j++) {
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) {
valid = false
break
}
}
if (!valid) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
if (this._verifyAesKey(keyBytes, ciphertext))
return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
private _isAlphaNum(b: number): boolean {
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
return (b >= 0x61 && b <= 0x7a) || (b >= 0x41 && b <= 0x5a) || (b >= 0x30 && b <= 0x39)
}
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
@@ -1221,12 +1069,14 @@ export class KeyService {
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
// 支持 JPEG / PNG / WEBP / WXGF / GIF
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0xff && dec[1] === 0xd8 && dec[2] === 0xff) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4e && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch { return false }
} catch {
return false
}
}
}
+124
View File
@@ -0,0 +1,124 @@
import { safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import { LEGACY_MIGRATION_RESULT_FD_ENV, LEGACY_MIGRATION_SOURCE_ENV } from './app-data-bootstrap'
import { isValidDatabaseKey } from './database-key-store'
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
export interface LegacySecretFailure {
asset: string
error: string
}
export interface LegacySecretBundle {
token?: string
aiProviderKeys?: { version: 1; keys: Record<string, string> }
imageKeys?: {
version: 1
accounts: Record<string, { xorKey: string; aesKey: string; updatedAt: number }>
}
legacyDatabaseKey?: string
databaseKeys: Record<string, string>
failures: LegacySecretFailure[]
}
function isStringRecord(value: unknown): value is Record<string, string> {
return (
Boolean(value) &&
typeof value === 'object' &&
Object.values(value as Record<string, unknown>).every((item) => typeof item === 'string')
)
}
function decryptFile(filePath: string): string {
return safeStorage.decryptString(fs.readFileSync(filePath))
}
export function decryptLegacySecrets(sourceRoot: string): LegacySecretBundle {
const result: LegacySecretBundle = { databaseKeys: {}, failures: [] }
if (!safeStorage.isEncryptionAvailable()) {
result.failures.push({ asset: 'safeStorage', error: '系统安全存储不可用' })
return result
}
const readSecret = (
asset: string,
relativePath: string,
apply: (plain: string) => void
): void => {
const filePath = path.join(sourceRoot, relativePath)
if (!fs.pathExistsSync(filePath)) return
try {
apply(decryptFile(filePath))
} catch {
result.failures.push({ asset, error: '旧加密数据无法解密或格式无效' })
}
}
readSecret('local-api-token.bin', 'local-api-token.bin', (plain) => {
if (!TOKEN_PATTERN.test(plain)) throw new Error('invalid token')
result.token = plain
})
readSecret('ai-provider-keys.bin', 'ai-provider-keys.bin', (plain) => {
const parsed = JSON.parse(plain) as { version?: unknown; keys?: unknown }
if (parsed.version !== 1 || !isStringRecord(parsed.keys))
throw new Error('invalid provider keys')
result.aiProviderKeys = { version: 1, keys: parsed.keys }
})
readSecret('wechat-image-keys.bin', 'wechat-image-keys.bin', (plain) => {
const parsed = JSON.parse(plain) as {
version?: unknown
accounts?: Record<string, { xorKey?: unknown; aesKey?: unknown; updatedAt?: unknown }>
}
if (
parsed.version !== 1 ||
!parsed.accounts ||
Object.values(parsed.accounts).some(
(entry) =>
!entry ||
typeof entry.xorKey !== 'string' ||
typeof entry.aesKey !== 'string' ||
typeof entry.updatedAt !== 'number'
)
) {
throw new Error('invalid image keys')
}
result.imageKeys = {
version: 1,
accounts: parsed.accounts as NonNullable<LegacySecretBundle['imageKeys']>['accounts']
}
})
readSecret('wechat-db-key.bin', 'wechat-db-key.bin', (plain) => {
if (!isValidDatabaseKey(plain)) throw new Error('invalid legacy database key')
result.legacyDatabaseKey = plain.trim().replace(/^0x/i, '')
})
const databaseKeysRoot = path.join(sourceRoot, 'database-keys')
if (fs.pathExistsSync(databaseKeysRoot)) {
for (const entry of fs.readdirSync(databaseKeysRoot, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.bin')) continue
readSecret(`database-keys/${entry.name}`, path.join('database-keys', entry.name), (plain) => {
if (!isValidDatabaseKey(plain)) throw new Error('invalid database key')
result.databaseKeys[entry.name] = plain.trim().replace(/^0x/i, '')
})
}
}
return result
}
export function writeLegacySecretHelperResult(result: LegacySecretBundle): void {
const fd = Number(process.env[LEGACY_MIGRATION_RESULT_FD_ENV] || '3')
if (!Number.isInteger(fd) || fd < 3) throw new Error('invalid migration result pipe')
fs.writeFileSync(fd, JSON.stringify(result), 'utf8')
}
export function runLegacySafeStorageHelper(): void {
const sourceRoot = process.env[LEGACY_MIGRATION_SOURCE_ENV]?.trim()
if (!sourceRoot) throw new Error('legacy migration source is missing')
writeLegacySecretHelperResult(decryptLegacySecrets(path.resolve(sourceRoot)))
}
+7 -7
View File
@@ -65,6 +65,7 @@ type VideoContent = {
md5?: string
newMd5?: string
rawMd5?: string
byteLength?: number
duration?: number
width?: number
height?: number
@@ -122,6 +123,9 @@ export type ParsedContent =
| UnknownContent
export function parseMessageContent(content: string, messageType: number): ParsedContent {
// Voice rows may keep their binary payload outside msgContent, so an empty
// content string is still a valid voice message.
if (messageType === 34) return { type: 'voice' }
if (!content || typeof content !== 'string') {
return { type: 'unknown', raw: content || '' }
}
@@ -131,8 +135,6 @@ export function parseMessageContent(content: string, messageType: number): Parse
switch (messageType) {
case 1:
return { type: 'text', content: normalized }
case 34:
return { type: 'voice' }
case 3:
return parseImageMessage(normalized)
case 42:
@@ -160,12 +162,11 @@ function parseVideoMessage(content: string): ParsedContent {
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
const byteLength = Number(extractXmlAttribute(decoded, 'videomsg', 'length')) || undefined
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
return { type: 'video', md5, newMd5, rawMd5, byteLength, duration, width, height }
}
function parseSystemMessage(content: string): ParsedContent {
@@ -490,8 +491,7 @@ function parseShareMessage(content: string): ParsedContent {
}
const articles = parseShareArticles(content)
const title =
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
const title = articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
const des =
articles[0]?.description ||
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
+1 -1
View File
@@ -33,5 +33,5 @@ try {
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
} catch (error) {
console.error('[WechatExplorer] failed to enforce local DLL priority:', error)
console.error('[TraceMemo] failed to enforce local DLL priority:', error)
}
+1
View File
@@ -12,6 +12,7 @@ export function getResourceRoots(): string[] {
const execDir = dirname(process.execPath)
return unique([
process.env.TRACEMEMO_RESOURCES_PATH || '',
process.env.WECHATEXPLORER_RESOURCES_PATH || '',
join(process.cwd(), 'resources'),
join(process.cwd(), 'resources', 'resources'),
+5 -5
View File
@@ -455,7 +455,7 @@ class AgentHubService {
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return
}
const items = listRecentChat(limit)
@@ -474,7 +474,7 @@ class AgentHubService {
const result = await agentAIProvider.chat([
{
role: 'system',
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
content: `你是 TraceMemo 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
1. recent limit 1-20
2. contact contact limit
@@ -544,7 +544,7 @@ class AgentHubService {
intent: ContactChatIntent
): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return
}
@@ -580,7 +580,7 @@ class AgentHubService {
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
@@ -642,7 +642,7 @@ class AgentHubService {
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return
}
const group = this.resolveGroup(intent.group)
+1 -1
View File
@@ -80,7 +80,7 @@ const agentSystemPrompt = (
question: string,
scopeLabel: string,
rangeLabel: string
): string => `你是 WechatExplorer 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
): string => `你是 TraceMemo 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
${question}
${scopeLabel}${rangeLabel}
+13 -2
View File
@@ -1,4 +1,4 @@
import { app, session } from 'electron'
import { app, session, shell } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import { clearBootstrapCache } from './bootstrap-cache'
@@ -62,7 +62,8 @@ export function getCacheSummary(): CacheSummary {
{
id: 'knowledge',
label: '本地知识库索引',
description: '为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
description:
'为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
...knowledge
}
]
@@ -90,3 +91,13 @@ export async function clearCache(
}
return getCacheSummary()
}
export async function openKnowledgeDirectory(): Promise<{ success: boolean; error?: string }> {
try {
await fs.ensureDir(KNOWLEDGE_CACHE_DIR)
const error = await shell.openPath(KNOWLEDGE_CACHE_DIR)
return error ? { success: false, error } : { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
+9 -1
View File
@@ -9,7 +9,12 @@ import type {
DatabaseKeyValidationCode,
DatabaseKeyValidationResult
} from '../../shared/database-key'
import {
isWindowsVcRuntimeMissingError,
WINDOWS_VC_RUNTIME_ERROR_MESSAGE
} from '../../shared/windows-runtime'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality'
export function getCurrentKey(): string {
if (!dbRef) return ''
@@ -60,6 +65,7 @@ export interface FormattedMessage {
exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string
exportMediaQuality?: ExportImageQuality
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
@@ -295,7 +301,7 @@ function listSourceMessages(
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
if (!isPatMessage && [3, 34, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const isQuotePayload = /<refermsg\b/i.test(content)
const hasStickerPayload =
@@ -673,11 +679,13 @@ const DATABASE_KEY_ERROR_MESSAGES: Record<DatabaseKeyValidationCode, string> = {
ACCOUNT_MISMATCH: '密钥与当前账号不匹配',
ROOT_UNAVAILABLE: '当前数据库目录不可用',
DATABASE_FILE_MISSING: '数据库文件缺失',
VC_RUNTIME_MISSING: WINDOWS_VC_RUNTIME_ERROR_MESSAGE,
UNKNOWN_VALIDATION_ERROR: '未知验证错误'
}
function mapConnectionError(detail: string): DatabaseKeyValidationCode {
const normalized = detail.toLowerCase()
if (isWindowsVcRuntimeMissingError(detail, process.platform)) return 'VC_RUNTIME_MISSING'
if (normalized.includes('-1005') || normalized.includes('不匹配')) return 'ACCOUNT_MISMATCH'
if (normalized.includes('session.db') || normalized.includes('数据库文件')) {
return 'DATABASE_FILE_MISSING'
@@ -28,6 +28,7 @@ export async function inspectImageDecryptionStatus(
const imageDirectoryFound = hasImageDirectory(accountRoot)
const stickerCacheFound =
fs.existsSync(path.join(accountRoot, 'cache')) ||
fs.existsSync(path.join(os.homedir(), 'Documents', 'TraceMemo', 'Emojis')) ||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
const dbConnected = chat.isReady()
const [wechatRunning, decoder] = await Promise.all([
@@ -287,7 +288,7 @@ export function buildImageTestDiagnosticLog(input: {
const rootIsDirectory = rootExists ? safeIsDirectory(root) : false
const resultCode = input.result.success ? 'SUCCESS' : input.result.code || 'UNKNOWN'
return [
'WechatExplorer 图片解析测试日志(已脱敏)',
'TraceMemo 图片解析测试日志(已脱敏)',
`时间:${new Date().toISOString()}`,
`应用版本:${safeAppVersion()}`,
`运行环境:${process.platform} ${process.arch}`,
+1 -1
View File
@@ -1,5 +1,5 @@
// src/main/services/image-insight-service.ts
// WechatExplorer AI 图片理解基础设施
// TraceMemo AI 图片理解基础设施
//
// 设计原则:
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
+19 -17
View File
@@ -3,10 +3,12 @@ import { existsSync, promises as fs } from 'fs'
import { dirname, join } from 'path'
import { isPackagedRuntime } from '../runtime-mode'
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
const GITHUB_URL =
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
const SKILL_VERSION = 'v1.1'
const SKILL_RELATIVE_PATHS = [
join('skill', 'tracememo-reader', 'SKILL.md'),
join('skill', 'wechatexplorer-reader', 'SKILL.md')
]
const GITHUB_URL = 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
const SKILL_VERSION = 'v1.2'
type SkillResourceSource = 'development' | 'bundled'
@@ -54,19 +56,19 @@ function uniqueCandidates(candidates: SkillCandidate[]): SkillCandidate[] {
export function getSkillCandidates(environment?: SkillPathEnvironment): SkillCandidate[] {
const runtime = environment || currentEnvironment()
const developmentPaths = [
join(runtime.appPath, 'docs', SKILL_RELATIVE_PATH),
join(runtime.cwd, 'docs', SKILL_RELATIVE_PATH),
join(dirname(runtime.appPath), 'docs', SKILL_RELATIVE_PATH)
]
const developmentPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
join(runtime.appPath, 'docs', relativePath),
join(runtime.cwd, 'docs', relativePath),
join(dirname(runtime.appPath), 'docs', relativePath)
])
const execDirectory = dirname(runtime.execPath)
const bundledPaths = [
join(runtime.resourcesPath, SKILL_RELATIVE_PATH),
join(runtime.resourcesPath, 'resources', SKILL_RELATIVE_PATH),
join(dirname(runtime.appPath), SKILL_RELATIVE_PATH),
join(execDirectory, 'resources', SKILL_RELATIVE_PATH),
join(dirname(execDirectory), 'Resources', SKILL_RELATIVE_PATH)
]
const bundledPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
join(runtime.resourcesPath, relativePath),
join(runtime.resourcesPath, 'resources', relativePath),
join(dirname(runtime.appPath), relativePath),
join(execDirectory, 'resources', relativePath),
join(dirname(execDirectory), 'Resources', relativePath)
])
return uniqueCandidates(
runtime.packaged
? bundledPaths.map((path) => ({ path, source: 'bundled' }))
@@ -90,7 +92,7 @@ export function resolveSkillResourceStatus(
available: false,
source,
githubUrl: GITHUB_URL,
error: `未找到 WechatExplorer Reader Skill 文件(已检查:${candidates.map((item) => item.path).join('')}`
error: `未找到 TraceMemo Reader Skill 文件(已检查:${candidates.map((item) => item.path).join('')}`
}
}
return {
+6 -3
View File
@@ -19,9 +19,12 @@ const downloadCache = new Map<string, Promise<StickerResult>>()
export class StickerService {
private readonly cacheDir: string
private readonly legacyCacheDir: string
constructor(private readonly wcdb4Client?: Wcdb4Client | null) {
this.cacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
this.cacheDir = path.join(os.homedir(), 'Documents', 'TraceMemo', 'Emojis')
// Keep reading the former directory so existing sticker caches remain usable.
this.legacyCacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
}
async resolveSticker(cdnUrl?: string, md5?: string): Promise<StickerResult> {
@@ -69,7 +72,7 @@ export class StickerService {
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
const cacheDirs = [
this.cacheDir,
path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
this.legacyCacheDir
]
for (const cacheDir of cacheDirs) {
for (const ext of extensions) {
@@ -128,7 +131,7 @@ export class StickerService {
url,
{
headers: {
'User-Agent': 'Mozilla/5.0 MicroMessenger WechatExplorer',
'User-Agent': 'Mozilla/5.0 MicroMessenger TraceMemo',
Referer: 'https://weixin.qq.com/'
}
},
+23 -1
View File
@@ -10,6 +10,7 @@ type VideoAsset = {
export type VideoResolveOptions = {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
@@ -52,7 +53,13 @@ export class VideoAssetService {
.filter((value) => /^[a-f0-9]{32}$/.test(value))
)
)
if (candidates.length === 0) return { success: false, error: '视频标识为空' }
const hasMetadata =
Number(options.byteLength) > 0 ||
Number(options.duration) > 0 ||
(Number(options.width) > 0 && Number(options.height) > 0)
if (candidates.length === 0 && !hasMetadata) {
return { success: false, error: '视频标识为空' }
}
const hardlinkDb = path.join(
this.client.getAccountRoot(),
@@ -178,6 +185,21 @@ export class VideoAssetService {
let narrowed = assets
let appliedCriteria = 0
const byteLength = Number(options.byteLength)
if (byteLength > 0) {
const matches = narrowed.filter((asset) => {
try {
return fs.statSync(asset.filePath).size === byteLength
} catch {
return false
}
})
if (matches.length > 0) {
narrowed = matches
appliedCriteria += 1
}
}
const width = Number(options.width)
const height = Number(options.height)
if (width > 0 && height > 0) {
+207 -146
View File
@@ -58,6 +58,17 @@ export interface Wcdb4GroupMember {
m_nsHeadImgUrl: string
}
/**
*
*
* nickname
*
*/
type Wcdb4ContactMemberNames = {
wechatNickname: string
remark: string
}
export interface Wcdb4ImageHardlink {
file_name?: string
full_path?: string
@@ -261,20 +272,38 @@ export function resolveWindowsNativeAccountRoot(
.find((candidate) => candidate && isAsciiPath(candidate))
if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot
const bridgeRoot = path.join(publicRoot, 'WechatExplorer', 'path-bridges')
const bridgePath = path.join(
bridgeRoot,
crypto
.createHash('sha256')
.update(path.resolve(accountRoot).toLowerCase())
.digest('hex')
.slice(0, 24)
)
const bridgeId = crypto
.createHash('sha256')
.update(path.resolve(accountRoot).toLowerCase())
.digest('hex')
.slice(0, 24)
const legacyBridgePath = path.join(publicRoot, 'WechatExplorer', 'path-bridges', bridgeId)
try {
if (fs.existsSync(legacyBridgePath)) {
const legacyTarget = fs.realpathSync.native(legacyBridgePath)
const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
if (
path.resolve(legacyTarget).toLowerCase() ===
path.resolve(canonicalAccountRoot).toLowerCase()
) {
return legacyBridgePath
}
}
} catch {
// An unreadable legacy junction is left untouched; TraceMemo creates its own bridge below.
}
const bridgeRoot = path.join(publicRoot, 'TraceMemo', 'path-bridges')
const bridgePath = path.join(bridgeRoot, bridgeId)
try {
fs.ensureDirSync(bridgeRoot)
if (fs.existsSync(bridgePath)) {
const existingTarget = fs.realpathSync.native(bridgePath)
if (path.resolve(existingTarget).toLowerCase() === path.resolve(accountRoot).toLowerCase()) {
const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
if (
path.resolve(existingTarget).toLowerCase() ===
path.resolve(canonicalAccountRoot).toLowerCase()
) {
return bridgePath
}
fs.removeSync(bridgePath)
@@ -1684,70 +1713,16 @@ export class Wcdb4Client {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
)
const members = (Array.isArray(rows) ? rows : []).map((row) => {
const username = this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const wechatNickname = this.pickString(row, [
'nickname',
'nickName',
'wechatNickname',
'wechat_nickname',
'm_nsNickName'
])
const remark = this.pickString(row, [
'remark',
'remarkName',
'remark_name',
'contactRemark',
'contact_remark'
])
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username) {
if (avatar) this.avatarCache.set(username, avatar)
}
return {
m_nsUsrName: username,
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
groupNickname: groupNicknames.get(username) || '',
wechatNickname: wechatNickname || memberNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
const missingDisplayNames = members
.filter((member) => !member.nickname)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
this.hydrateDisplayNames(missingDisplayNames)
const memberRows = Array.isArray(rows) ? rows : []
const contactNames = this.readContactMemberNames(this.groupMemberUsernames(memberRows))
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
this.hydrateAvatarUrls(
members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
)
return members.map((member) => ({
...member,
nickname:
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
wechatNickname:
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
return this.withCachedGroupMemberAvatars(members)
} catch {
return []
}
@@ -1821,70 +1796,98 @@ export class Wcdb4Client {
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
chatroomId
)
const members = (Array.isArray(rows) ? rows : []).map((row) => {
const username = this.pickString(row, [
const memberRows = Array.isArray(rows) ? rows : []
const contactNames = await this.readContactMemberNamesAsync(
this.groupMemberUsernames(memberRows)
)
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
const missingAvatars = members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
await this.hydrateAvatarUrlsAsync(missingAvatars)
return this.withCachedGroupMemberAvatars(members)
} catch (error) {
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
return []
}
}
/**
*
*
* contact 使
* nickname退
*/
private normalizeGroupMembers(
rows: Record<string, unknown>[],
groupNicknames: Map<string, string>,
contactNames: Map<string, Wcdb4ContactMemberNames>
): Wcdb4GroupMember[] {
return rows.map((row) => {
const username = this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const rowGroupNickname = this.pickString(row, [
'groupNickname',
'group_nickname',
'displayName',
'display_name'
])
const groupNickname = groupNicknames.get(username) || rowGroupNickname
const contact = contactNames.get(username)
const wechatNickname = contact?.wechatNickname || ''
const remark = contact?.remark || ''
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username && avatar) this.avatarCache.set(username, avatar)
return {
m_nsUsrName: username,
// nickname 是旧调用方使用的通用显示字段,保持安全的名称优先级。
nickname: wechatNickname || groupNickname || username,
groupNickname,
wechatNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
}
/**
* SQL IN
*/
private groupMemberUsernames(rows: Record<string, unknown>[]): string[] {
return rows
.map((row) =>
this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const wechatNickname = this.pickString(row, [
'nickname',
'nickName',
'wechatNickname',
'wechat_nickname',
'm_nsNickName'
])
const remark = this.pickString(row, [
'remark',
'remarkName',
'remark_name',
'contactRemark',
'contact_remark'
])
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username && avatar) this.avatarCache.set(username, avatar)
return {
m_nsUsrName: username,
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
groupNickname: groupNicknames.get(username) || '',
wechatNickname: wechatNickname || memberNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
)
.filter(Boolean)
}
const missingNames = members
.filter((member) => !member.nickname)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
const missingAvatars = members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
await Promise.all([
this.hydrateDisplayNamesAsync(missingNames),
this.hydrateAvatarUrlsAsync(missingAvatars)
])
return members.map((member) => ({
...member,
nickname:
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
wechatNickname:
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
} catch (error) {
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
return []
}
/**
* 退
*/
private withCachedGroupMemberAvatars(members: Wcdb4GroupMember[]): Wcdb4GroupMember[] {
return members.map((member) => ({
...member,
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
}
getGroupNicknames(chatroomId: string): Map<string, string> {
@@ -2655,27 +2658,6 @@ export class Wcdb4Client {
return false
}
private hydrateDisplayNames(usernames: string[]): void {
if (!this.wcdbGetDisplayNames) return
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
if (missing.length === 0) return
try {
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
(handle, outJson) => this.wcdbGetDisplayNames!(handle, JSON.stringify(missing), outJson)
)
this.readStringMap(rows, [
'nickname',
'displayName',
'display_name',
'remark',
'name'
]).forEach((name, username) => this.displayNameCache.set(username, name))
} catch {
// Names are optional; usernames are still enough to load chats.
}
}
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
if (!this.wcdbGetDisplayNames) return
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
@@ -2781,6 +2763,85 @@ export class Wcdb4Client {
}
}
/**
* contact
*
* 退 wxid
* 使 nickname
*/
private readContactMemberNames(usernames: string[]): Map<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
const inList = this.uniq(usernames)
.map((username) => `'${username.replace(/'/g, "''")}'`)
.join(',')
if (!inList) return result
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(
handle,
'contact',
'',
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`,
outJson
)
)
return this.contactMemberNamesFromRows(rows)
} catch {
return result
}
}
/**
* contact
*/
private async readContactMemberNamesAsync(
usernames: string[]
): Promise<Map<string, Wcdb4ContactMemberNames>> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
const inList = this.uniq(usernames)
.map((username) => `'${username.replace(/'/g, "''")}'`)
.join(',')
if (!inList) return result
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'contact',
'',
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`
)
return this.contactMemberNamesFromRows(rows)
} catch {
return result
}
}
/**
* contact wxid
*/
private contactMemberNamesFromRows(
rows: Record<string, unknown>[]
): Map<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!Array.isArray(rows)) return result
for (const row of rows) {
const username = this.pickString(row, ['username', 'user_name', 'userName', 'm_nsUsrName'])
if (!username) continue
result.set(username, {
wechatNickname: this.pickString(row, ['nick_name', 'nickName', 'm_nsNickName']),
remark: this.pickString(row, ['remark', 'remark_name', 'remarkName'])
})
}
return result
}
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
const result = new Map<string, string>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
+16 -6
View File
@@ -10,6 +10,7 @@ import {
} from '../shared/report-history'
import type {
DatabaseKeyEnvironment,
DatabaseInitResult,
DatabaseKeyStorageResult,
DatabaseKeyValidationResult,
AccountDiscoveryResult
@@ -57,7 +58,8 @@ import type {
VoiceModelDownloadResult,
VoiceModelProgressEvent,
VoiceModelStatus,
VoiceRecognitionResult
VoiceRecognitionResult,
VoiceTranscriptSnapshot
} from '../shared/voice-recognition'
import type {
AiSearchCancelResult,
@@ -102,6 +104,7 @@ export type ParsedContent =
md5?: string
newMd5?: string
rawMd5?: string
byteLength?: number
duration?: number
width?: number
height?: number
@@ -140,10 +143,8 @@ declare global {
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
getCacheSummary: () => Promise<CacheSummary>
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
initDb: (
key: string,
accountRoot: string
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
openKnowledgeDirectory: () => Promise<{ success: boolean; error?: string }>
initDb: (key: string, accountRoot: string) => Promise<boolean | DatabaseInitResult>
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
getBootstrapCache: () => Promise<{
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
@@ -247,6 +248,9 @@ declare global {
removeVoiceModel: () => Promise<VoiceModelStatus>
openVoiceModelDirectory: () => Promise<{ success: boolean; error?: string }>
recognizeVoice: (reference: VoiceMessageReference) => Promise<VoiceRecognitionResult>
getVoiceTranscriptSnapshot: (
reference: VoiceMessageReference
) => Promise<VoiceTranscriptSnapshot>
cancelVoiceRecognition: (reference: VoiceMessageReference) => Promise<{ success: boolean }>
getVoiceBatchPreflight: (request: VoiceBatchRequest) => Promise<VoiceBatchPreflight>
getVoiceBatchConversationSummaries: (
@@ -278,7 +282,13 @@ declare global {
}>
getVideo: (
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
options?: {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
}
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
getSticker: (
cdnUrl?: string,
+15 -2
View File
@@ -34,7 +34,8 @@ import type {
VoiceModelDownloadResult,
VoiceModelProgressEvent,
VoiceModelStatus,
VoiceRecognitionResult
VoiceRecognitionResult,
VoiceTranscriptSnapshot
} from '../shared/voice-recognition'
import type {
AiSearchCancelResult,
@@ -66,6 +67,8 @@ const api = {
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<CacheSummary> =>
ipcRenderer.invoke('cache:clear', scope),
openKnowledgeDirectory: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('cache:openKnowledgeDirectory'),
initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
ipcRenderer.invoke('accounts:discover', inputPath),
@@ -137,6 +140,10 @@ const api = {
ipcRenderer.invoke('voice:openModelDirectory'),
recognizeVoice: (reference: VoiceMessageReference): Promise<VoiceRecognitionResult> =>
ipcRenderer.invoke('voice:recognize', reference),
getVoiceTranscriptSnapshot: (
reference: VoiceMessageReference
): Promise<VoiceTranscriptSnapshot> =>
ipcRenderer.invoke('voice:getTranscriptSnapshot', reference),
cancelVoiceRecognition: (reference: VoiceMessageReference): Promise<{ success: boolean }> =>
ipcRenderer.invoke('voice:cancelRecognition', reference),
getVoiceBatchPreflight: (request: VoiceBatchRequest): Promise<VoiceBatchPreflight> =>
@@ -179,7 +186,13 @@ const api = {
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
options?: {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
}
) => ipcRenderer.invoke('db:getVideo', hashes, options),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
+1 -1
View File
@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<title>WechatExplorer</title>
<title>TraceMemo(迹忆)</title>
<link rel="icon" type="image/svg+xml" href="/src/assets/brand-icon.svg" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
+15 -6
View File
@@ -378,12 +378,19 @@ function App(): React.ReactElement {
const handleStartExport = async (
request: ExportRequest
): Promise<import('../../shared/export').ExportResult> => {
const targetNames = request.targets.map((target) => target.name)
const targetLabel =
targetNames.length > 1 ? `${targetNames[0]}${targetNames.length} 个聊天` : targetNames[0]
const exportAll = request.scope === 'all'
const targetCount = request.targets.length
const targetNames = exportAll ? [] : request.targets.map((target) => target.name)
const targetLabel = exportAll
? `全部 ${targetCount} 个聊天`
: targetNames.length > 1
? `${targetNames[0]}${targetNames.length} 个聊天`
: targetNames[0]
const task: ExportTaskRecord = {
jobId: request.jobId,
targetIds: request.targets.map((target) => target.userMd5),
scope: request.scope,
allContactTypes: request.allContactTypes,
targetIds: exportAll ? [] : request.targets.map((target) => target.userMd5),
targetNames,
targetLabel,
format: request.format,
@@ -798,7 +805,7 @@ function App(): React.ReactElement {
})
setStartupProgress({
title: '正在加载账号信息...',
subtitle: '即将进入 WechatExplorer',
subtitle: '即将进入 TraceMemo',
detail: '正在读取联系人和当前账号',
percent: 70
})
@@ -1677,6 +1684,8 @@ function App(): React.ReactElement {
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
onRetry={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.retry()
@@ -1804,7 +1813,7 @@ function App(): React.ReactElement {
? autoConnectSource === 'env'
? '检测到环境变量中的密钥'
: '使用上次安全保存的密钥'
: 'WechatExplorer')
: 'TraceMemo')
return (
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
<div className="boot-splash-spinner" aria-hidden />
@@ -1,5 +1,6 @@
import React from 'react'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
const GUIDE_URL =
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
@@ -140,12 +141,12 @@ export function DatabaseConnectionPage({
return (
<main className="database-login-page">
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
<section className="database-login-brand" aria-label="TraceMemo(迹忆)产品说明">
<div className="database-login-brand-content">
<div className="database-login-logo" aria-hidden="true">
<LineIcon name="database" />
</div>
<h1>WechatExplorer</h1>
<h1>TraceMemo</h1>
<p className="database-login-tagline"> AI </p>
<p className="database-login-description">
使 AI
@@ -260,7 +261,7 @@ export function DatabaseConnectionPage({
'确认下方检测结果;没有找到目录时可以手动选择。',
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
'开始后请按页面提示完成系统授权。',
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
'正在准备连接组件,请不要关闭微信或 TraceMemo。',
'请回到微信完成登录,登录成功后再回来验证。',
'正在验证密钥和本地数据库,请稍候。'
][guideStep - 1]}
@@ -461,7 +462,12 @@ export function DatabaseConnectionPage({
</a>
</>
) : (
'Windows 已完整支持,不需要关闭 SIP。'
<>
Windows Microsoft Visual C++ 2015-2022 x64 {' '}
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
</a>
</>
)}
</p>
{showMacKeyFaq && isMac && (
@@ -521,6 +527,14 @@ export function DatabaseConnectionPage({
</div>
)}
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
{platform === 'win32' && (
<p className="database-login-platform-note">
Microsoft Visual C++ 2015-2022 x64 {' '}
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
</a>
</p>
)}
<button
type="button"
className="database-login-primary"
@@ -553,7 +567,7 @@ export function DatabaseConnectionPage({
<button type="button" onClick={onClearKey}>
</button>
<span>WechatExplorer</span>
<span>TraceMemo</span>
</div>
</div>
</section>
@@ -478,7 +478,7 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
API Bearer Token Token API Center
Claude / Codex
<br />
:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
<code>docs/skill/tracememo-reader/SKILL.md</code>
</div>
</section>
+4 -2
View File
@@ -5,6 +5,7 @@ interface VideoBubbleProps {
newMd5?: string
rawMd5?: string
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
@@ -15,6 +16,7 @@ export function VideoBubble({
newMd5,
rawMd5,
createTime,
byteLength,
duration,
width,
height
@@ -28,7 +30,7 @@ export function VideoBubble({
useEffect(() => {
let cancelled = false
window.api
.getVideo(hashes, { createTime, duration, width, height })
.getVideo(hashes, { createTime, byteLength, duration, width, height })
.then((result) => {
if (!cancelled) setMedia(result.success ? result : { error: result.error })
})
@@ -38,7 +40,7 @@ export function VideoBubble({
return () => {
cancelled = true
}
}, [createTime, duration, hashes, height, width])
}, [byteLength, createTime, duration, hashes, height, width])
if (!media.url) {
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
@@ -82,6 +82,7 @@ export function MessageBubble({
newMd5={message.contentData.newMd5}
rawMd5={message.contentData.rawMd5}
createTime={message.createTime}
byteLength={message.contentData.byteLength}
duration={message.contentData.duration}
width={message.contentData.width}
height={message.contentData.height}
@@ -1,4 +1,5 @@
import React from 'react'
import type { ExportContactType } from '../../../../shared/export'
import type { Contact, SelfInfo } from './exportTypes'
import { displayName } from './exportUtils'
@@ -8,6 +9,9 @@ interface ExportContactPanelProps {
activeContact: Contact | null
selectedContactIds: string[]
selectionMode: boolean
exportAll: boolean
allContactTypes: ExportContactType[]
exportRunning: boolean
selectionLimit: number
selfInfo: SelfInfo | null
dbReady: boolean
@@ -17,6 +21,8 @@ interface ExportContactPanelProps {
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
onSelectContact: (contact: Contact) => void
onCompleteSelection: () => void
onExportAll: () => void
onToggleAllContactType: (type: ExportContactType) => void
onOpenSettings: () => void
}
@@ -26,6 +32,9 @@ export function ExportContactPanel({
activeContact,
selectedContactIds,
selectionMode,
exportAll,
allContactTypes,
exportRunning,
selectionLimit,
selfInfo,
dbReady,
@@ -35,8 +44,16 @@ export function ExportContactPanel({
onContactTypeChange,
onSelectContact,
onCompleteSelection,
onExportAll,
onToggleAllContactType,
onOpenSettings
}: ExportContactPanelProps): React.ReactElement {
const groupCount = contacts.filter((contact) => contact.type === 'group').length
const userCount = contacts.length - groupCount
const selectedAllCount =
(allContactTypes.includes('group') ? groupCount : 0) +
(allContactTypes.includes('user') ? userCount : 0)
return (
<aside className="export-contact-panel">
<div className="export-panel-header">
@@ -71,9 +88,55 @@ export function ExportContactPanel({
</button>
))}
</div>
<button
type="button"
className={`export-all-button ${exportAll ? 'active' : ''}`}
aria-pressed={exportAll}
onClick={onExportAll}
>
<span>
<strong></strong>
<small></small>
</span>
<b>{(exportAll ? selectedAllCount : contacts.length).toLocaleString()}</b>
</button>
{exportAll && (
<div className="export-all-type-options" aria-label="全部导出范围">
{(
[
['group', '群聊'],
['user', '联系人']
] as const
).map(([type, label]) => {
const count = type === 'group' ? groupCount : userCount
return (
<label key={type}>
<input
type="checkbox"
aria-label={`导出全部${label}`}
checked={allContactTypes.includes(type)}
disabled={
exportRunning || (allContactTypes.length === 1 && allContactTypes[0] === type)
}
onChange={() => onToggleAllContactType(type)}
/>
<span>{label}</span>
<b>{count.toLocaleString()}</b>
</label>
)
})}
</div>
)}
</div>
{selectionMode && (
{exportAll ? (
<div className="export-all-status">
{allContactTypes.includes('group') ? `全部群聊 ${groupCount}` : ''}
{allContactTypes.length === 2 ? '和' : ''}
{allContactTypes.includes('user') ? `全部联系人 ${userCount}` : ''}
</div>
) : selectionMode ? (
<div className="export-multi-select-bar">
<span>
{selectedContactIds.length} / {selectionLimit}
@@ -82,21 +145,24 @@ export function ExportContactPanel({
</button>
</div>
)}
) : null}
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
const selected = selectedContactIds.includes(contact.md5)
const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit
const selectedByAll = exportAll && allContactTypes.includes(contact.type)
const visuallySelected = exportAll ? selectedByAll : selected
const atLimit =
!exportAll && selectionMode && !selected && selectedContactIds.length >= selectionLimit
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
className={`export-contact-item ${!exportAll && activeContact?.md5 === contact.md5 ? 'active' : ''} ${visuallySelected ? 'selected' : ''}`}
onClick={() => onSelectContact(contact)}
disabled={atLimit}
aria-pressed={selected}
aria-pressed={visuallySelected}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
@@ -105,7 +171,7 @@ export function ExportContactPanel({
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
{selectionMode && (
{!exportAll && selectionMode && (
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
{selected ? '✓' : ''}
</span>
@@ -13,6 +13,7 @@ interface ExportPreviewPanelProps {
includeVoiceTranscripts: boolean
zip: boolean
selectedCount: number
allExport: boolean
jobId: string
onCancel: (jobId: string) => void
onReveal: (path: string) => void
@@ -28,6 +29,7 @@ export function ExportPreviewPanel({
includeVoiceTranscripts,
zip,
selectedCount,
allExport,
jobId,
onCancel,
onReveal
@@ -61,6 +63,9 @@ export function ExportPreviewPanel({
: phase === 'parsing'
? `正在解析消息内容... ${percent}%`
: `正在读取消息... ${percent}%`
const currentTargetText = progress?.currentTargetName
? `${progress.currentTargetIndex || 1}/${progress.currentTargetCount || selectedCount} 个:${progress.currentTargetName}`
: ''
return (
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
@@ -69,23 +74,39 @@ export function ExportPreviewPanel({
<div className="export-preview-heading">
<strong></strong>
<span>
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
{allExport
? `${selectedCount} 个聊天 · 分目录导出`
: selectedCount > 1
? `${selectedCount} 个聊天 · 合并预览`
: '仅预览最近 20 条'}
</span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
{(previewItems.length
? previewItems
: [
<div className="export-preview-date">{allExport ? '全量归档' : '最近消息'}</div>
{(allExport
? [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
id: 'all-export',
from: 'system',
content: '每个聊天将保存为独立 HTML 档案',
type: '系统消息',
datetime: '',
isSender: false
isSender: false,
contentData: { type: 'system' as const, content: '全量归档' }
}
]
: previewItems.length
? previewItems
: [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
datetime: '',
isSender: false
}
]
).map((message) => (
<div
key={`${message.exportConversationId || 'single'}:${message.id}`}
@@ -146,6 +167,12 @@ export function ExportPreviewPanel({
<div className="export-job-state">
<h2></h2>
<p></p>
{currentTargetText && (
<div className="export-current-target">
<span>{progress?.currentTargetType === 'group' ? '群聊' : '联系人'}</span>
<strong>{currentTargetText}</strong>
</div>
)}
<ol>
<li className="done"></li>
{steps.map((step, index) => (
@@ -197,7 +224,7 @@ export function ExportPreviewPanel({
className="export-primary-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
>
{allExport ? '打开导出目录' : '打开档案'}
</button>
<button
type="button"
@@ -22,6 +22,9 @@ const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
}
const taskDetail = (task: ExportTaskRecord): string | null => {
if (task.status === 'running' && task.progress.currentTargetName) {
return `${task.progress.currentTargetIndex || 1}/${task.progress.currentTargetCount || '?'} 个:${task.progress.currentTargetName}`
}
if (task.status === 'completed') {
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
}
@@ -42,12 +45,13 @@ export function ExportTaskCenter({
const copyTaskLog = async (task: ExportTaskRecord): Promise<void> => {
const log = [
'WechatExplorer 导出任务日志',
'TraceMemo 导出任务日志',
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
`会话:${task.targetLabel}`,
`格式:${task.format.toUpperCase()}`,
`状态:${task.progress.phase}`,
`进度:${task.progress.percent ?? 0}%`,
`当前聊天:${task.progress.currentTargetName || '未开始'}`,
`错误:${task.progress.error || '未记录具体错误'}`
].join('\n')
await navigator.clipboard.writeText(log)
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'
import type { Message } from '../../../../shared/types'
import type {
ExportContactType,
ExportJobProgress,
ExportMessageKind,
ExportNameMode,
@@ -20,6 +21,11 @@ import type {
} from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
import { resolveMemberName } from '../../../../shared/member-names'
const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user']
const contactTypeKey = (types: ExportContactType[] | undefined): string =>
[...(types?.length ? types : ALL_CONTACT_TYPES)].sort().join('|')
export function ExportWorkspace({
contacts,
@@ -33,17 +39,26 @@ export function ExportWorkspace({
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const runningAllTask = exportTasks.find(
(task) => task.scope === 'all' && task.status === 'running'
)
const initialContactRef = React.useRef<Contact | null>(initialSelection)
const previewLoadingRef = React.useRef(new Set<string>())
const [contactFilter, setContactFilter] = useState('')
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
const [selectionMode, setSelectionMode] = useState(false)
const [exportAll, setExportAll] = useState(() => Boolean(runningAllTask))
const [allContactTypes, setAllContactTypes] = useState<ExportContactType[]>(() =>
runningAllTask?.allContactTypes?.length
? [...runningAllTask.allContactTypes]
: [...ALL_CONTACT_TYPES]
)
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
initialSelection ? [initialSelection] : []
)
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>('today')
const [range, setRange] = useState<ExportRange>(() => (runningAllTask ? 'all' : 'today'))
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
@@ -57,8 +72,8 @@ export function ExportWorkspace({
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(true)
const [format, setFormat] = useState<ExportFormat>('csv')
const [zip, setZip] = useState(false)
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
const [fileName, setFileName] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
@@ -80,6 +95,7 @@ export function ExportWorkspace({
}, [contacts, initialContact, selectedContacts.length])
React.useEffect(() => {
if (exportAll) return
for (const contact of selectedContacts) {
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
previewLoadingRef.current.add(contact.md5)
@@ -88,7 +104,7 @@ export function ExportWorkspace({
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
})
}
}, [loadPreviewMessages, previewByContact, selectedContacts])
}, [exportAll, loadPreviewMessages, previewByContact, selectedContacts])
const filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase()
@@ -105,31 +121,48 @@ export function ExportWorkspace({
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
selectedContacts[0] ||
null
const selectedTargetKey = selectedContacts
.map((contact) => contact.md5)
.sort()
.join('|')
const currentTask = exportTasks.find(
(task) => [...task.targetIds].sort().join('|') === selectedTargetKey
const exportContacts = exportAll
? contacts.filter((contact) => allContactTypes.includes(contact.type))
: selectedContacts
const selectedTargetKey = exportAll
? ''
: selectedContacts
.map((contact) => contact.md5)
.sort()
.join('|')
const currentTask = exportTasks.find((task) =>
exportAll
? task.scope === 'all' &&
contactTypeKey(task.allContactTypes) === contactTypeKey(allContactTypes)
: task.scope !== 'all' && [...task.targetIds].sort().join('|') === selectedTargetKey
)
const taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact)
const selectedNames = selectedContacts.map(displayName)
const selectedLabel =
selectedNames.length > 1
const allGroupCount = exportContacts.filter((contact) => contact.type === 'group').length
const allUserCount = exportContacts.length - allGroupCount
const selectedLabel = exportAll
? allContactTypes.length === 2
? `全部群聊 ${allGroupCount.toLocaleString()} 个、联系人 ${allUserCount.toLocaleString()}`
: allContactTypes[0] === 'group'
? `全部群聊 ${allGroupCount.toLocaleString()}`
: `全部联系人 ${allUserCount.toLocaleString()}`
: selectedNames.length > 1
? `${selectedNames.join('、')} · 共 ${selectedNames.length} 个聊天`
: selectedNames[0] || '未选择聊天'
const preview = selectedContacts
.flatMap((contact) =>
(previewByContact[contact.md5] || []).map((message) => ({
...message,
exportConversationId: contact.md5,
exportConversationName: displayName(contact),
exportConversationAvatarUrl: contact.avatar
}))
)
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
.slice(-20)
const preview = exportAll
? []
: selectedContacts
.flatMap((contact) =>
(previewByContact[contact.md5] || []).map((message) => ({
...message,
exportConversationId: contact.md5,
exportConversationName: displayName(contact),
exportConversationAvatarUrl: contact.avatar
}))
)
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
.slice(-20)
const previewMediaCount = preview.filter(
(message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
@@ -139,12 +172,13 @@ export function ExportWorkspace({
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
0
)
const defaultOutputName =
selectedContacts.length > 1
const defaultOutputName = exportAll
? '全部聊天记录'
: selectedContacts.length > 1
? `${selectedNames[0]}${selectedContacts.length}个聊天_合并档案`
: `${activeName}_聊天档案`
const outputName = fileName.trim() || defaultOutputName
const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some(
const nameOptions: { value: ExportNameMode; label: string }[] = exportContacts.some(
(contact) => contact.type === 'group'
)
? [
@@ -170,6 +204,10 @@ export function ExportWorkspace({
}))
const handleSelectContact = (contact: Contact): void => {
if (exportAll) {
setExportAll(false)
setRange('today')
}
if (!selectionMode) {
setSelectedContacts([contact])
setActiveContactId(contact.md5)
@@ -193,6 +231,26 @@ export function ExportWorkspace({
setStatus('idle')
}
const handleExportAll = (): void => {
if (!contacts.length || status === 'running') return
setExportAll(true)
setAllContactTypes([...ALL_CONTACT_TYPES])
setSelectionMode(false)
setRange('all')
setStatus('idle')
}
const toggleAllContactType = (type: ExportContactType): void => {
if (status === 'running') return
setAllContactTypes((current) => {
if (current.includes(type)) {
return current.length === 1 ? current : current.filter((item) => item !== type)
}
return ALL_CONTACT_TYPES.filter((item) => item === type || current.includes(item))
})
setStatus('idle')
}
React.useEffect(() => {
let active = true
void window.api
@@ -214,10 +272,11 @@ export function ExportWorkspace({
}
const handleStart = async (): Promise<void> => {
if (!activeContact || selectedContacts.length === 0 || status === 'running') return
if (!activeContact || exportContacts.length === 0 || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job.
const nextJobId = `export-${Date.now()}`
const exportFormat = selectedContacts.length > 1 ? 'html' : format
const exportFormat = !exportAll && exportContacts.length > 1 ? 'html' : format
const shouldZip = exportFormat === 'html' && zip
const shouldIncludeVoiceTranscripts =
includeVoiceTranscripts &&
includeMedia &&
@@ -226,31 +285,28 @@ export function ExportWorkspace({
voiceModelStatus?.state === 'ready'
setJobId(nextJobId)
setProgress(null)
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip })
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip: shouldZip })
setStatus('running')
const targets: ExportTarget[] = await Promise.all(
selectedContacts.map(async (contact) => {
exportContacts.map(async (contact) => {
const nameMap: Record<string, string> = {}
const avatarUrls: Record<string, string> = {}
if (contact.type === 'group') {
const snapshot = await window.api.getGroupSnapshot(contact.md5)
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
nameMap[member.wxid] =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
if (member.avatar) avatarUrls[member.wxid] = member.avatar
if (!exportAll) {
const snapshot = await window.api.getGroupSnapshot(contact.md5)
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
nameMap[member.wxid] = resolveMemberName(member, nameMode)
if (member.avatar) avatarUrls[member.wxid] = member.avatar
}
}
} else {
} else if (!exportAll) {
nameMap[contact.m_nsUsrName] =
nameMode === 'remark'
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
: contact.wechatNickname || contact.m_nsUsrName
if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar
}
if (selfInfo?.wxid) {
if (selfInfo?.wxid && (!exportAll || contact.type === 'group')) {
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
}
@@ -273,19 +329,25 @@ export function ExportWorkspace({
: null
const request: ExportRequest = {
jobId: nextJobId,
scope: exportAll ? 'all' : 'selected',
allContactTypes: exportAll ? allContactTypes : undefined,
targets,
format: exportFormat,
outputName,
startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
: range === 'custom' && startDate
? Math.floor(new Date(startDate).getTime() / 1000)
: undefined,
endTime: startOfRange
? Math.floor(endOfToday.getTime() / 1000)
: range === 'custom' && endDate
? Math.floor(new Date(endDate).getTime() / 1000)
: undefined,
startTime: exportAll
? undefined
: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
: range === 'custom' && startDate
? Math.floor(new Date(startDate).getTime() / 1000)
: undefined,
endTime: exportAll
? undefined
: startOfRange
? Math.floor(endOfToday.getTime() / 1000)
: range === 'custom' && endDate
? Math.floor(new Date(endDate).getTime() / 1000)
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
includeVoiceTranscripts: shouldIncludeVoiceTranscripts,
@@ -293,7 +355,7 @@ export function ExportWorkspace({
fallbackThumbnail,
keepMissing,
includeAvatars,
zip
zip: shouldZip
}
const result = await onStartExport(request)
if (result.success) {
@@ -330,6 +392,8 @@ export function ExportWorkspace({
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
zip: currentTask.zip === true
})
setFormat(currentTask.format)
setZip(currentTask.zip === true)
setStatus(
currentTask.status === 'running'
? 'running'
@@ -344,6 +408,8 @@ export function ExportWorkspace({
setSelectedContacts(contact ? [contact] : [])
setActiveContactId(contact?.md5 || '')
setSelectionMode(false)
setExportAll(false)
setAllContactTypes([...ALL_CONTACT_TYPES])
setRange('today')
setStartDate('')
setEndDate('')
@@ -363,12 +429,15 @@ export function ExportWorkspace({
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
}
const targetPath =
format === 'html'
const targetPath = exportAll
? format === 'html' && zip
? `文稿/TraceMemo/导出/${outputName}.zip`
: `文稿/TraceMemo/导出/${outputName}/`
: format === 'html'
? zip
? `文稿/WechatExplorer/导出/${outputName}.zip`
: `文稿/WechatExplorer/导出/${outputName}/`
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
? `文稿/TraceMemo/导出/${outputName}.zip`
: `文稿/TraceMemo/导出/${outputName}/`
: `文稿/TraceMemo/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
return (
<div className="export-workspace">
@@ -378,6 +447,9 @@ export function ExportWorkspace({
activeContact={activeContact}
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
selectionMode={selectionMode}
exportAll={exportAll}
allContactTypes={allContactTypes}
exportRunning={status === 'running'}
selectionLimit={selectionLimit}
selfInfo={selfInfo}
dbReady={dbReady}
@@ -387,6 +459,8 @@ export function ExportWorkspace({
onContactTypeChange={setContactType}
onSelectContact={handleSelectContact}
onCompleteSelection={() => setSelectionMode(false)}
onExportAll={handleExportAll}
onToggleAllContactType={toggleAllContactType}
onOpenSettings={onOpenSettings}
/>
@@ -401,15 +475,24 @@ export function ExportWorkspace({
/>
<header className="export-config-header">
<span className="export-chat-avatar-stack" aria-hidden>
{selectedContacts.slice(0, 3).map((contact) => (
<span className="export-chat-avatar" key={contact.md5}>
{contact.avatar ? (
<img src={contact.avatar} alt="" />
) : (
displayName(contact).slice(0, 1)
)}
</span>
))}
{exportAll
? allContactTypes.map((type) => (
<span
className={`export-chat-avatar export-all-chat-avatar ${type}`}
key={type}
>
{type === 'group' ? '群' : '联'}
</span>
))
: selectedContacts.slice(0, 3).map((contact) => (
<span className="export-chat-avatar" key={contact.md5}>
{contact.avatar ? (
<img src={contact.avatar} alt="" />
) : (
displayName(contact).slice(0, 1)
)}
</span>
))}
</span>
<span className="export-config-title">
<h1></h1>
@@ -418,9 +501,13 @@ export function ExportWorkspace({
<button
type="button"
className="export-add-chat-button"
onClick={() => setSelectionMode((current) => !current)}
disabled={exportAll}
onClick={() => {
setExportAll(false)
setSelectionMode((current) => !current)
}}
>
{selectionMode ? '完成选择' : '+ 添加聊天'}
{exportAll ? '已选择全部聊天' : selectionMode ? '完成选择' : '+ 添加聊天'}
</button>
</header>
@@ -432,7 +519,7 @@ export function ExportWorkspace({
key={value}
type="button"
className={format === value ? 'active' : ''}
disabled={selectedContacts.length > 1 && value !== 'html'}
disabled={!exportAll && exportContacts.length > 1 && value !== 'html'}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
@@ -441,9 +528,11 @@ export function ExportWorkspace({
))}
</div>
<p className="export-helper-text">
{selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
{exportAll
? '全部导出固定使用全部时间;每个群聊或联系人都会在自己的目录中生成所选格式的独立档案。'
: selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
</p>
{format === 'html' && (
<>
@@ -482,34 +571,45 @@ export function ExportWorkspace({
<div className="export-range-toggle">
<button
type="button"
className={range === 'today' ? 'active' : ''}
onClick={() => setRange('today')}
className={range === 'all' ? 'active' : ''}
onClick={() => setRange('all')}
>
</button>
<button
type="button"
className={range === 'threeDays' ? 'active' : ''}
onClick={() => setRange('threeDays')}
>
3
</button>
<button
type="button"
className={range === 'sevenDays' ? 'active' : ''}
onClick={() => setRange('sevenDays')}
>
7
</button>
<button
type="button"
className={range === 'custom' ? 'active' : ''}
onClick={() => setRange('custom')}
>
</button>
{!exportAll && (
<>
<button
type="button"
className={range === 'today' ? 'active' : ''}
onClick={() => setRange('today')}
>
</button>
<button
type="button"
className={range === 'threeDays' ? 'active' : ''}
onClick={() => setRange('threeDays')}
>
3
</button>
<button
type="button"
className={range === 'sevenDays' ? 'active' : ''}
onClick={() => setRange('sevenDays')}
>
7
</button>
<button
type="button"
className={range === 'custom' ? 'active' : ''}
onClick={() => setRange('custom')}
>
</button>
</>
)}
</div>
{range === 'custom' && (
{!exportAll && range === 'custom' && (
<div className="export-date-fields">
<label>
@@ -683,7 +783,7 @@ export function ExportWorkspace({
<button
type="button"
className="export-primary-button"
disabled={!activeContact || status === 'running'}
disabled={!activeContact || !exportContacts.length || status === 'running'}
onClick={handleStart}
>
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
@@ -700,7 +800,8 @@ export function ExportWorkspace({
progress={progress}
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
zip={activeJobOptions.zip}
selectedCount={selectedContacts.length}
selectedCount={exportContacts.length}
allExport={exportAll}
jobId={jobId}
onCancel={(exportJobId) => {
void window.api.cancelExport(exportJobId)
@@ -8,7 +8,7 @@ import type {
ExportTaskRecord
} from '../../../../shared/export'
export type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
export type ExportRange = 'all' | 'today' | 'threeDays' | 'sevenDays' | 'custom'
export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
export type ExportStatus = 'idle' | 'running' | 'completed'
@@ -1,5 +1,6 @@
import type { Contact, Message } from '../../../../shared/types'
import type { ExportFormat, GroupMemberName } from './exportTypes'
import { resolveMemberName } from '../../../../shared/member-names'
export const messageKinds = [
['text', '文字'],
@@ -43,13 +44,10 @@ export function buildNameMap(
const map: Record<string, string> = {}
if (activeContact?.type === 'group') {
for (const member of groupMembers) {
const value =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
map[member.wxid] = value
map[member.wxid] = resolveMemberName(
member,
nameMode as 'groupNickname' | 'remark' | 'wechatNickname'
)
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
@@ -26,7 +26,7 @@ interface AppShellProps {
function BrandLogo(): React.ReactElement {
return (
<div className="app-brand" title="WechatExplorer" aria-label="WechatExplorer">
<div className="app-brand" title="TraceMemo(迹忆)" aria-label="TraceMemo(迹忆)">
<img src={brandIcon} alt="" aria-hidden="true" />
</div>
)
@@ -13,6 +13,7 @@ import { ModelSummary } from './ModelSummary'
import { ReportDensitySelector } from './ReportDensitySelector'
import { ReportRangeSelector } from './ReportRangeSelector'
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
import { ReportSectionSelector } from './ReportSectionSelector'
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
@@ -169,6 +170,7 @@ export function AiReportWorkspace({
onChange={onMemberNamePreferenceChange}
disabled={configDisabled}
/>
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
<section className="report-config-section report-timeout-section">
<div>
@@ -192,7 +194,7 @@ export function AiReportWorkspace({
<h3></h3>
<p></p>
<p></p>
<p>WechatExplorer </p>
<p>TraceMemo </p>
</section>
{generatedImage && (
@@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../shared/types'
interface ReportGroupMember {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
interface ReportGroupMemberSelectorProps {
sourceContact: Contact | null
disabled?: boolean
}
interface GroupMemberSnapshotState {
contactId: string
members: ReportGroupMember[]
error: string
}
const displayMemberName = (member: ReportGroupMember): string =>
member.groupNickname || member.wechatNickname || member.remark || member.nickname || member.wxid
export function ReportGroupMemberSelector({
sourceContact,
disabled
}: ReportGroupMemberSelectorProps): React.ReactElement | null {
const [snapshotState, setSnapshotState] = useState<GroupMemberSnapshotState>({
contactId: '',
members: [],
error: ''
})
const [selectedWxid, setSelectedWxid] = useState('')
const [filter, setFilter] = useState('')
const sourceContactId = sourceContact?.type === 'group' ? sourceContact.md5 : ''
useEffect(() => {
if (!sourceContactId) return
let active = true
void window.api
.getGroupSnapshot(sourceContactId)
.then((snapshot) => {
if (!active) return
setSnapshotState({
contactId: sourceContactId,
members: (snapshot?.members || []) as ReportGroupMember[],
error: ''
})
})
.catch((loadError) => {
if (!active) return
setSnapshotState({
contactId: sourceContactId,
members: [],
error: loadError instanceof Error ? loadError.message : '群成员信息读取失败'
})
})
return () => {
active = false
}
}, [sourceContactId])
const filteredMembers = useMemo(() => {
const loadedMembers = snapshotState.contactId === sourceContactId ? snapshotState.members : []
const keyword = filter.trim().toLowerCase()
if (!keyword) return loadedMembers
return loadedMembers.filter((member) =>
[member.wxid, member.nickname, member.groupNickname, member.wechatNickname, member.remark]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(keyword))
)
}, [filter, snapshotState, sourceContactId])
const loading = Boolean(sourceContactId) && snapshotState.contactId !== sourceContactId
const error = snapshotState.contactId === sourceContactId ? snapshotState.error : ''
const selectedMember =
filteredMembers.find((member) => member.wxid === selectedWxid) || filteredMembers[0] || null
if (!sourceContact || sourceContact.type !== 'group') return null
return (
<section className="report-section report-group-member-selector">
<h3></h3>
<p className="report-section-desc">
使
</p>
<div className="report-member-tools">
<input
type="search"
value={filter}
disabled={disabled || loading}
onChange={(event) => setFilter(event.target.value)}
placeholder="搜索成员或 wxid"
aria-label="搜索群成员"
/>
<select
value={selectedMember?.wxid || ''}
disabled={disabled || loading || !filteredMembers.length}
onChange={(event) => setSelectedWxid(event.target.value)}
aria-label="选择群成员"
>
{filteredMembers.length ? (
filteredMembers.map((member) => (
<option key={member.wxid} value={member.wxid}>
{displayMemberName(member)}
</option>
))
) : (
<option value=""></option>
)}
</select>
</div>
{loading && <p className="report-member-status">...</p>}
{error && <p className="report-member-status error">{error}</p>}
{selectedMember && (
<dl className="report-member-details">
<div>
<dt></dt>
<dd>{selectedMember.groupNickname || '未设置'}</dd>
</div>
<div>
<dt></dt>
<dd>{selectedMember.wechatNickname || '未读取到'}</dd>
</div>
<div>
<dt></dt>
<dd>{selectedMember.remark || '未设置'}</dd>
</div>
<div>
<dt>wxid</dt>
<dd className="report-member-wxid">{selectedMember.wxid}</dd>
</div>
</dl>
)}
</section>
)
}
@@ -1,31 +1,29 @@
import React, { useEffect, useState } from 'react'
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
import {
REPORT_TASK_STEPS,
ReportGenerationPhase,
VoiceTranscriptionProgress
} from '../../hooks/useGroupReportGeneration'
interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase
error: string
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
voiceTranscriptionEnabled: boolean
onRetry: () => void
}
const TASK_STEPS: Array<{
id: Exclude<ReportGenerationPhase, 'idle' | 'success' | 'error'>
label: string
}> = [
{ id: 'loadingMessages', label: '读取聊天记录' },
{ id: 'preparingInput', label: '整理日报输入' },
{ id: 'requestingModel', label: '调用模型生成内容' },
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
]
const phaseIndex = (phase: ReportGenerationPhase): number =>
TASK_STEPS.findIndex((step) => step.id === phase)
export function ReportTaskStatusPanel({
phase,
error,
voiceTranscriptionProgress,
voiceTranscriptionEnabled,
onRetry
}: ReportTaskStatusPanelProps): React.ReactElement {
const activeIndex = phaseIndex(phase)
const taskSteps = voiceTranscriptionEnabled
? REPORT_TASK_STEPS
: REPORT_TASK_STEPS.filter((step) => step.id !== 'transcribingVoice')
const activeIndex = taskSteps.findIndex((step) => step.id === phase)
const completedAll = phase === 'success'
const [logPath, setLogPath] = useState('')
@@ -46,12 +44,12 @@ export function ReportTaskStatusPanel({
: phase === 'error'
? '生成失败'
: activeIndex >= 0
? `${activeIndex + 1}/${TASK_STEPS.length}`
? `${activeIndex + 1}/${taskSteps.length}`
: '等待开始'}
</p>
</div>
<div className="report-task-steps">
{TASK_STEPS.map((step, index) => {
{taskSteps.map((step, index) => {
const state =
completedAll || (activeIndex >= 0 && index < activeIndex)
? 'done'
@@ -71,6 +69,25 @@ export function ReportTaskStatusPanel({
)
})}
</div>
{phase === 'transcribingVoice' && voiceTranscriptionProgress && (
<div className="report-voice-progress">
<div>
<span></span>
<strong>
{voiceTranscriptionProgress.processed}/{voiceTranscriptionProgress.total}
</strong>
</div>
<progress
value={voiceTranscriptionProgress.processed}
max={Math.max(1, voiceTranscriptionProgress.total)}
aria-label="语音转写进度"
/>
<small>
{voiceTranscriptionProgress.succeeded} {voiceTranscriptionProgress.failed}{' '}
</small>
</div>
)}
{phase === 'error' && (
<div className="report-task-error">
<b></b>
@@ -1094,7 +1094,7 @@ export function AISearchWorkspace({
<div className="ai-search-workspace">
<header className="ai-search-header">
<div>
<span className="ai-search-kicker">WechatExplorer · LOCAL INTELLIGENCE</span>
<span className="ai-search-kicker">TraceMemo · LOCAL INTELLIGENCE</span>
<h1></h1>
<p></p>
</div>
@@ -1392,6 +1392,13 @@ export function AISearchWorkspace({
ref={composerRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) {
return
}
event.preventDefault()
void runAnalysis()
}}
placeholder="例如:技术交流群最近讨论了哪些 Windows 性能问题?"
rows={2}
/>
@@ -95,7 +95,7 @@ export function AgentHubWorkspace(): React.ReactElement {
<div className="agent-hub-workspace">
<header className="agent-hub-header">
<div>
<div className="agent-hub-eyebrow">WechatExplorer</div>
<div className="agent-hub-eyebrow">TraceMemo</div>
<h1>Agent Hub</h1>
<p> AI </p>
</div>
@@ -193,7 +193,7 @@ export function AgentHubWorkspace(): React.ReactElement {
<aside className="agent-hub-card agent-hub-capability-card">
<span className="agent-hub-card-kicker"></span>
<h2></h2>
<p> Agent Hub WechatExplorer</p>
<p> Agent Hub TraceMemo</p>
<div className="agent-hub-example">
<span></span>
<strong> 5 </strong>
@@ -195,7 +195,7 @@ export function ApiRuntimePanel({
<h3></h3>
<p>
{localOnly
? '本地 API 默认监听 127.0.0.1。WechatExplorer 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
? '本地 API 默认监听 127.0.0.1。TraceMemo 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
: '当前服务并非仅本机访问。请确认局域网环境可信;API Token 不等同于公网安全防护。'}
</p>
</section>
@@ -37,13 +37,13 @@ export function ReaderSkillOverview({
<div className="api-workspace-heading">
<div>
<div className="api-title-line">
<h1>WechatExplorer Reader</h1>
<h1>TraceMemo Reader</h1>
<span className={`api-skill-status ${skill?.available ? 'ready' : 'error'}`}>
{skill?.available ? '已安装' : '文件不可用'}
</span>
<span className="api-version">{skill?.version || 'v1.0'}</span>
</div>
<p> HTTP API WechatExplorer </p>
<p> HTTP API TraceMemo </p>
</div>
<div className="api-header-actions">
<button type="button" onClick={onPreview} disabled={!skill?.available}>
@@ -79,9 +79,9 @@ export function ReaderSkillOverview({
<div className="api-introduction">
<h2></h2>
<p>
WechatExplorer Reader AI Agent
TraceMemo Reader AI Agent
WechatExplorer API
TraceMemo API
</p>
<div className="api-flow">
<span>AI Agent</span>
@@ -18,7 +18,11 @@ export function SkillDetails({
<dl>
<div>
<dt></dt>
<dd>wechatexplorer-reader</dd>
<dd>TraceMemo Reader</dd>
</div>
<div>
<dt></dt>
<dd>tracememo-reader</dd>
</div>
<div>
<dt></dt>
@@ -43,7 +43,7 @@ export function SkillInstallFlow({
<section className={service?.running && dbReady ? 'done' : 'active'}>
<b>1</b>
<div>
<h3> WechatExplorer </h3>
<h3> TraceMemo </h3>
<p>
API{service?.running ? '运行中' : '已停止'} · {address}
</p>
@@ -16,12 +16,12 @@ export function SkillPreviewDialog({
className="api-markdown-overlay"
role="dialog"
aria-modal="true"
aria-label="WechatExplorer Reader Skill 预览"
aria-label="TraceMemo Reader Skill 预览"
>
<div>
<header>
<div>
<strong>WechatExplorer Reader</strong>
<strong>TraceMemo Reader</strong>
<span>{version || 'v1.0'}</span>
</div>
<div>
@@ -18,7 +18,7 @@ export const REPORT_REQUEST_PRESET = JSON.stringify(
activeUsers: 0,
timeSpan: '00:00-23:59',
generatedAt: '2026-07-13 22:00',
recordNote: '本日报由 WechatExplorer 自动生成',
recordNote: '本日报由 TraceMemo 自动生成',
footerNote: '',
heroParticipants: [],
avatars: {},
@@ -37,7 +37,7 @@ export const AGENT_GROUP_REPORT_PRESET = JSON.stringify(
)
export const AGENT_SEND_PRESET = JSON.stringify(
{ to: '', text: 'WechatExplorer Agent Hub 发送测试' },
{ to: '', text: 'TraceMemo Agent Hub 发送测试' },
null,
2
)
@@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string }
]
export const githubLatestUrl =
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
export const githubVersionedInstallUrl: string | null = null
@@ -7,13 +7,13 @@ function requestHost(host: string): string {
function opening(target: AgentInstallTarget): string {
switch (target) {
case 'codex':
return '请将本地目录中的 WechatExplorer Reader Skill 安装到当前 Codex 项目或用户 Skill 目录:'
return '请将本地目录中的 TraceMemo Reader Skill 安装到当前 Codex 项目或用户 Skill 目录:'
case 'claude-code':
return '请安装以下本地 WechatExplorer Reader Skill,并按照 SKILL.md 调用本地 HTTP API'
return '请安装以下本地 TraceMemo Reader Skill,并按照 SKILL.md 调用本地 HTTP API'
case 'openclaw':
return '请将以下本地目录作为 WechatExplorer Reader Skill 安装,并阅读其中的 SKILL.md'
return '请将以下本地目录作为 TraceMemo Reader Skill 安装,并阅读其中的 SKILL.md'
default:
return '请读取并安装以下 WechatExplorer Reader Skill'
return '请读取并安装以下 TraceMemo Reader Skill'
}
}
@@ -32,10 +32,10 @@ export function buildSkillInstallInstruction({
const sourceText =
source.type === 'local'
? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:`
: `请从以下地址安装 WechatExplorer Reader Skill\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:`
return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 WechatExplorer → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 WECHATEXPLORER_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $WECHATEXPLORER_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。`
: `请从以下地址安装 TraceMemo Reader Skill\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:`
return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 TraceMemo → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 TRACEMEMO_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $TRACEMEMO_API_TOKEN。若旧配置尚未升级,可兼容读取 WECHATEXPLORER_API_TOKEN,但新配置必须优先使用 TRACEMEMO_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。`
}
export function buildSkillVerificationPrompt(): string {
return '请检查 WechatExplorer Reader 是否已连接,然后列出最近 5 个微信会话。'
return '请检查 TraceMemo Reader 是否已连接,然后列出最近 5 个微信会话。'
}
@@ -75,7 +75,7 @@ export function AIImageUnderstandingTest({
</label>
<p className="ai-vision-privacy">
AI WechatExplorer
AI TraceMemo
</p>
{state.error ? <p className="ai-vision-error">{state.error}</p> : null}
@@ -30,7 +30,7 @@ export function DatabaseKeyAutoDetect({
<div className="database-key-auto-heading">
<div>
<strong>Windows </strong>
<p>WechatExplorer </p>
<p>TraceMemo </p>
</div>
<button
type="button"
@@ -63,7 +63,7 @@ export function DatabaseKeyDangerZone({
>
<h2 id="database-key-confirm-title"></h2>
<p>
WechatExplorer
TraceMemo
</p>
<div>
@@ -99,7 +99,7 @@ export function DatabaseKeyDangerZone({
>
<h2 id="database-key-return-confirm-title"></h2>
<p>
WechatExplorer
TraceMemo
</p>
<div>
@@ -26,7 +26,7 @@ export function buildDatabaseKeyDiagnostics(
): string {
const validation = state.validation
return [
'WechatExplorer 数据库密钥诊断',
'TraceMemo 数据库密钥诊断',
`已保存: ${state.saved ? '是' : '否'}`,
`已验证: ${validation?.success ? '是' : '否'}`,
`密钥长度合法: ${isDatabaseKeyFormatValid(input) ? '是' : '否'}`,
@@ -54,13 +54,13 @@ export function AboutPage({ onNotice }: { onNotice: (message: string) => void })
<header className="settings-page-header">
<div>
<h1></h1>
<p>WechatExplorer </p>
<p>TraceMemo AI </p>
</div>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<section className="settings-card about-identity-card">
<div><span className="settings-card-kicker"></span><strong>WechatExplorer</strong><small>v{update.currentVersion}</small></div>
<div><span className="settings-card-kicker"></span><strong>TraceMemo</strong><small>v{update.currentVersion}</small></div>
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">GitHub </a>
</section>
@@ -10,10 +10,14 @@ function formatBytes(value: number): string {
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`
}
export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
export function CacheCleanupPage({
onNotice
}: {
onNotice: (message: string) => void
}): React.ReactElement {
const [summary, setSummary] = useState<CacheSummary | null>(null)
const [busyScope, setBusyScope] = useState<
'bootstrap' | 'electron' | 'knowledge' | 'all' | 'local' | null
'bootstrap' | 'electron' | 'knowledge' | 'knowledge-directory' | 'all' | 'local' | null
>(null)
const refresh = useCallback(async (): Promise<void> => {
@@ -52,6 +56,19 @@ export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) =>
}
}
const openKnowledge = async (): Promise<void> => {
setBusyScope('knowledge-directory')
try {
const result = await window.api.openKnowledgeDirectory()
if (!result.success) throw new Error(result.error || '无法打开知识库文件夹')
onNotice('已打开知识库文件夹')
} catch (error) {
onNotice(error instanceof Error ? error.message : '无法打开知识库文件夹')
} finally {
setBusyScope(null)
}
}
return (
<div className="settings-page">
<header className="settings-page-header">
@@ -88,15 +105,28 @@ export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) =>
<div>
<h3>{item.label}</h3>
<p>{item.description}</p>
<small>{formatBytes(item.sizeBytes)} · {item.fileCount} </small>
<small>
{formatBytes(item.sizeBytes)} · {item.fileCount}
</small>
</div>
<div className="settings-cache-actions">
{item.id === 'knowledge' && (
<button
type="button"
disabled={busyScope !== null}
onClick={() => void openKnowledge()}
>
{busyScope === 'knowledge-directory' ? '打开中...' : '打开文件夹'}
</button>
)}
<button
type="button"
disabled={busyScope !== null}
onClick={() => void clear(item.id)}
>
{busyScope === item.id ? '清理中...' : '清理'}
</button>
</div>
<button
type="button"
disabled={busyScope !== null}
onClick={() => void clear(item.id)}
>
{busyScope === item.id ? '清理中...' : '清理'}
</button>
</section>
))}
<section className="settings-card settings-cache-item">
@@ -113,7 +143,9 @@ export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) =>
<div className="settings-inline-note">
<strong></strong>
<span></span>
<span>
</span>
</div>
</div>
</div>
@@ -76,7 +76,7 @@ export function DatabaseKeyPage({
<div>
<strong></strong>
<p>
WechatExplorer
TraceMemo
使
</p>
</div>
@@ -51,7 +51,7 @@ export function ImageDecryptionPage({
</svg>
<div>
<strong></strong>
<p>WechatExplorer </p>
<p>TraceMemo </p>
</div>
</section>
@@ -12,6 +12,14 @@ import {
SummaryMessageType
} from '../utils/group-report'
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
import {
transcribeVoiceMessages as transcribeReportVoiceMessages,
type VoiceTranscriptionProgress
} from '../utils/voice-message-reference'
import type { VoiceModelStatus } from '../../../shared/voice-recognition'
import { resolveMemberName } from '../../../shared/member-names'
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
const REPORT_STEP_TIMEOUT_MS = 90_000
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
@@ -19,6 +27,7 @@ const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
export type ReportGenerationPhase =
| 'idle'
| 'loadingMessages'
| 'transcribingVoice'
| 'preparingInput'
| 'requestingModel'
| 'exportingReport'
@@ -80,6 +89,7 @@ export interface ReportTaskStep {
export const REPORT_TASK_STEPS: ReportTaskStep[] = [
{ id: 'loadingMessages', label: '读取并筛选聊天记录' },
{ id: 'transcribingVoice', label: '转写语音消息' },
{ id: 'preparingInput', label: '整理日报输入' },
{ id: 'requestingModel', label: '调用模型生成内容' },
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
@@ -210,17 +220,10 @@ const applyGroupMemberNames = async (
const senderId = String(message.senderId || message.name || '')
const member = memberMap.get(senderId)
if (!member) return message
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
// Keep the three modes semantically distinct. `member.nickname` may
// already be a contact remark, so it must not leak into the first two.
groupNickname: [member.groupNickname, member.wechatNickname],
wechatNickname: [member.wechatNickname, member.groupNickname],
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
}
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
const name = resolveMemberName({ ...member, wxid: senderId }, preference)
return {
...message,
name: name || (preference === 'remark' ? message.name : ''),
name: !isInternalName(name) ? name : preference === 'remark' ? message.name : '',
img: message.img || member.avatar
}
})
@@ -238,6 +241,7 @@ export function useGroupReportGeneration({
reportMessages: Message[]
messageTypeCounts: Record<SummaryMessageType, number>
rangeState: RangeMessageState
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
generatedImage: string | null
reportPaths: ReportPaths | null
generationMetadata: ReportGenerationMetadata
@@ -260,6 +264,8 @@ export function useGroupReportGeneration({
const [error, setError] = useState('')
const [rangeMessages, setRangeMessages] = useState<Message[]>([])
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
useState<VoiceTranscriptionProgress | null>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
@@ -290,6 +296,7 @@ export function useGroupReportGeneration({
const isGenerating =
phase === 'loadingMessages' ||
phase === 'transcribingVoice' ||
phase === 'preparingInput' ||
phase === 'requestingModel' ||
phase === 'exportingReport'
@@ -367,9 +374,28 @@ export function useGroupReportGeneration({
setError('')
setGeneratedImage(null)
setReportPaths(null)
setVoiceTranscriptionProgress(null)
setGenerationMetadata({ generationLogs: [] })
}, [])
const transcribeSelectedVoiceMessages = useCallback(
async (messages: Message[]): Promise<Message[]> => {
setVoiceTranscriptionProgress(null)
return transcribeReportVoiceMessages(messages, {
getModelStatus: () =>
withTimeout(
window.api.getVoiceModelStatus(),
'检查语音模型'
) as Promise<VoiceModelStatus>,
getCachedTranscript: (reference) =>
withTimeout(window.api.getVoiceTranscriptSnapshot(reference), '读取语音缓存'),
recognize: (reference) => withTimeout(window.api.recognizeVoice(reference), '语音转写'),
onProgress: setVoiceTranscriptionProgress
})
},
[]
)
const generate = useCallback(async (): Promise<void> => {
if (isGenerating) return
if (!sourceContact) {
@@ -446,12 +472,16 @@ export function useGroupReportGeneration({
filteredMessageCount: filteredMessages.length
})
setPhase('preparingInput')
setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput')
failedAt = '整理日报输入'
const input = await trackStep('整理输入', async () => {
const messagesWithTranscripts = selectedTypes.has('语音')
? await transcribeSelectedVoiceMessages(filteredMessages)
: filteredMessages
setPhase('preparingInput')
const namedReportMessages = await applyGroupMemberNames(
sourceContact,
filteredMessages,
messagesWithTranscripts,
memberNamePreference
)
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
@@ -610,7 +640,8 @@ export function useGroupReportGeneration({
sourceContact,
summaryDateRange,
summaryMessageTypes,
templateId
templateId,
transcribeSelectedVoiceMessages
])
const clearError = useCallback((): void => {
@@ -639,6 +670,7 @@ export function useGroupReportGeneration({
reportMessages,
messageTypeCounts,
rangeState,
voiceTranscriptionProgress,
generatedImage,
reportPaths,
generationMetadata,
+121
View File
@@ -109,6 +109,89 @@
}
}
.export-all-button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 12px;
margin-top: 10px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
text-align: left;
span {
display: grid;
min-width: 0;
gap: 1px;
}
strong {
font-size: 12px;
}
small {
color: var(--wxex-text-muted);
font-size: 10px;
}
b {
color: var(--wxex-text-secondary);
font-size: 11px;
}
&.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
.export-all-status {
padding: 9px 16px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 11px;
line-height: 17px;
}
.export-all-type-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
label {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
min-width: 0;
padding: 7px 8px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
cursor: pointer;
font-size: 11px;
}
input {
margin: 0;
accent-color: var(--wxex-brand);
}
b {
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.export-contact-list {
flex: 1;
min-height: 0;
@@ -340,6 +423,21 @@
}
}
.export-all-chat-avatar {
border-color: #fff;
color: #fff;
font-size: 12px;
font-weight: 700;
&.group {
background: #2f7d5c;
}
&.user {
background: #416a8b;
}
}
.export-format-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
@@ -996,6 +1094,29 @@
font-weight: 500;
}
}
.export-current-target {
display: grid;
gap: 4px;
margin: 14px 0 4px;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: var(--wxex-bg-elevated);
span {
color: var(--wxex-text-muted);
font-size: 10px;
}
strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.export-progress-bar {
height: 6px;
overflow: hidden;

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