mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-19 04:27:00 +08:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3f115124 | ||
|
|
0c1d859e1b | ||
|
|
2482c23c5e | ||
|
|
0706ba13e6 | ||
|
|
55c6fb8cd3 | ||
|
|
b6901c9d0c | ||
|
|
4d95fd7650 | ||
|
|
775b5aff18 | ||
|
|
2f2f682fa0 | ||
|
|
2354fd0766 | ||
|
|
6192e7cd35 | ||
|
|
68f0c0b5a3 | ||
|
|
7a5499f093 | ||
|
|
d1a090bb6b | ||
|
|
8a0d3b02d9 | ||
|
|
c41675b809 | ||
|
|
4cd3ea0bc0 | ||
|
|
32864ff88c | ||
|
|
291c82f0e2 | ||
|
|
666d8896ee |
@@ -1 +1,2 @@
|
|||||||
shamefully-hoist=true
|
shamefully-hoist=true
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# WechatExplorer
|
# TraceMemo(迹忆)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./build/icon.png" width="120" alt="WechatExplorer Logo" />
|
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||||
·
|
·
|
||||||
@@ -26,16 +26,30 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/software-1.png" alt="WechatExplorer 主界面" />
|
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/机器人.png" alt="WechatExplorer 主界面" />
|
<img src="./public/机器人.png" alt="TraceMemo 主界面" />
|
||||||
</p>
|
</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 帮你找回聊过的内容,并回到原始消息核对答案。
|
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
|
||||||
|
|
||||||
@@ -47,6 +61,12 @@ WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台
|
|||||||
|
|
||||||
它和普通聊天记录查看器最大的不同,是 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) | 准备本地语音模型 |
|
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
|
||||||
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
|
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
|
||||||
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
|
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./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 |
|
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
|
||||||
|
|
||||||
## 最核心的三个能力
|
## 最核心的三个能力
|
||||||
@@ -76,7 +96,7 @@ WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台
|
|||||||
|
|
||||||
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
|
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
|
||||||
|
|
||||||
WechatExplorer 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
|
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
|
<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 服务。
|
这个入口不要求另外安装 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)
|
详细说明:[语音转文字](./docs/user-guide/voice.md)
|
||||||
|
|
||||||
### 防撤回
|
### 防撤回
|
||||||
|
|
||||||
可选开启后,WechatExplorer 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
|
可选开启后,TraceMemo 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
|
||||||
|
|
||||||
详细说明:[防撤回](./docs/user-guide/recall-protection.md)
|
详细说明:[防撤回](./docs/user-guide/recall-protection.md)
|
||||||
|
|
||||||
@@ -145,7 +165,7 @@ WechatExplorer 支持在本机转写单条或批量微信语音,结果可以
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A[本机微信数据] --> B[WechatExplorer 读取与解析]
|
A[本机微信数据] --> B[TraceMemo 读取与解析]
|
||||||
B --> C[聊天档案]
|
B --> C[聊天档案]
|
||||||
B --> D[本地知识库与搜索]
|
B --> D[本地知识库与搜索]
|
||||||
D --> E[筛选相关聊天来源]
|
D --> E[筛选相关聊天来源]
|
||||||
@@ -170,26 +190,30 @@ flowchart LR
|
|||||||
|
|
||||||
## 支持平台与安装包
|
## 支持平台与安装包
|
||||||
|
|
||||||
| 平台 | 处理器架构 | Releases 安装包 |
|
| 平台 | 处理器架构 | Releases 安装包 |
|
||||||
| ------- | ------------------------------------ | --------------- |
|
| ------- | ------------------------------ | --------------- |
|
||||||
| Windows | x64 | `-setup.exe` |
|
| Windows | x64 | `-setup.exe` |
|
||||||
| macOS | Intel(x64)、Apple Silicon(arm64) | `.dmg` |
|
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
|
||||||
|
|
||||||
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
|
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
|
||||||
2. 启动 WechatExplorer,按照“第一次使用”页面选择微信数据目录。
|
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
||||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
||||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
||||||
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
|
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
|
||||||
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
||||||
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
|
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)。
|
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||||
|
|
||||||
完整步骤:[第一次使用 WechatExplorer](./docs/user-guide/getting-started.md)
|
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
|
||||||
|
|
||||||
## 配置 AI
|
## 配置 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>
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
# WechatExplorer 文档
|
# TraceMemo 文档
|
||||||
|
|
||||||
WechatExplorer 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
||||||
|
|
||||||
## 从这里开始
|
## 从这里开始
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ WechatExplorer 的文档按“你想完成什么”组织,而不是按源码
|
|||||||
- [语音转文字](./user-guide/voice.md)
|
- [语音转文字](./user-guide/voice.md)
|
||||||
- [导出聊天档案](./user-guide/export.md)
|
- [导出聊天档案](./user-guide/export.md)
|
||||||
- [防撤回](./user-guide/recall-protection.md)
|
- [防撤回](./user-guide/recall-protection.md)
|
||||||
- [在微信里向 WechatExplorer 提问](./agent/agent-hub.md)
|
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
|
||||||
- [数据、隐私与安全](./user-guide/privacy.md)
|
- [数据、隐私与安全](./user-guide/privacy.md)
|
||||||
- [常见问题与排查](./user-guide/troubleshooting.md)
|
- [常见问题与排查](./user-guide/troubleshooting.md)
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ WechatExplorer 的文档按“你想完成什么”组织,而不是按源码
|
|||||||
|
|
||||||
## 微信机器人和外部 Agent
|
## 微信机器人和外部 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)
|
- [macOS 数据访问说明](./platform/macos.md)
|
||||||
- [开发、测试与构建](./development/overview.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)
|
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
|
||||||
|
|
||||||
当前工作区版本:**2.1.9**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||||
|
|||||||
@@ -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。
|
普通用户不需要安装 Reader Skill,也不需要配置 API Token。先连接微信数据库,再扫码登录机器人即可开始;需要总结或自然语言理解的任务还要配置 AI Provider。
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Agent Hub 是 WechatExplorer 内置的微信机器人入口,也是应用一级
|
|||||||
|
|
||||||
## 连接器和 Agent Hub 是什么关系
|
## 连接器和 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 服务;
|
- 依赖总结或自然语言理解的任务,需要在“设置 → AI 模型”配置可用的 AI 服务;
|
||||||
- WechatExplorer 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
|
- TraceMemo 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
|
||||||
|
|
||||||
## 安全与边界
|
## 安全与边界
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Bearer Token
|
||||||
|
|
||||||
|
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
|
||||||
|
|
||||||
- `/api/v1/health` 是公开健康检查;
|
- `/api/v1/health` 是公开健康检查;
|
||||||
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
||||||
- Token 由应用生成,使用 32 个随机字节编码;
|
- Token 由应用生成,使用 32 个随机字节编码;
|
||||||
@@ -17,7 +19,7 @@ WechatExplorer 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台
|
|||||||
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export WECHATEXPLORER_API_TOKEN="<TOKEN>"
|
export TRACEMEMO_API_TOKEN="<TOKEN>"
|
||||||
```
|
```
|
||||||
|
|
||||||
## CORS 与 Origin
|
## CORS 与 Origin
|
||||||
|
|||||||
+21
-19
@@ -1,4 +1,4 @@
|
|||||||
# WechatExplorer Local HTTP API
|
# TraceMemo Local HTTP API
|
||||||
|
|
||||||
本文面向需要自己写集成的开发者。普通用户请先阅读[Agent 接入概览](./overview.md)。
|
本文面向需要自己写集成的开发者。普通用户请先阅读[Agent 接入概览](./overview.md)。
|
||||||
|
|
||||||
@@ -17,29 +17,31 @@
|
|||||||
curl http://127.0.0.1:6131/api/v1/health
|
curl http://127.0.0.1:6131/api/v1/health
|
||||||
|
|
||||||
# 读取数据
|
# 读取数据
|
||||||
export WECHATEXPLORER_API_TOKEN="<从 API Center 复制的 Token>"
|
export TRACEMEMO_API_TOKEN="<从 API Center 复制的 Token>"
|
||||||
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"
|
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||||
```
|
```
|
||||||
|
|
||||||
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
||||||
|
|
||||||
|
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
|
||||||
|
|
||||||
## 端点
|
## 端点
|
||||||
|
|
||||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||||
| --- | --- | --- | --- |
|
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
|
||||||
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
||||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
| 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 秒;
|
- `YYYY-MM-DD/HH:mm`:从该分钟开始的 60 秒;
|
||||||
- 也可以使用 Unix 秒级 `startTime` 和 `endTime`。
|
- 也可以使用 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
|
```bash
|
||||||
BASE="http://127.0.0.1:6131/api/v1"
|
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/resolve?q=技术交流群"
|
||||||
curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||||
|
|||||||
@@ -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 中查询历史微信
|
## 在外部 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 的安装步骤
|
## 外部 Agent 的安装步骤
|
||||||
|
|
||||||
1. 启动 WechatExplorer 并完成微信数据库连接。
|
1. 启动 TraceMemo 并完成微信数据库连接。
|
||||||
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
|
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
|
||||||
3. 选择目标 Agent,点击“复制安装指令”。
|
3. 选择目标 Agent,点击“复制安装指令”。
|
||||||
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
|
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
|
||||||
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `WECHATEXPLORER_API_TOKEN`。
|
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `TRACEMEMO_API_TOKEN`。
|
||||||
6. 先让 Agent 调用 health,再尝试查询最近会话。
|
6. 先让 Agent 调用 health,再尝试查询最近会话。
|
||||||
|
|
||||||
详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。
|
详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。
|
||||||
|
|||||||
@@ -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 只负责“外部 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 服务和数据库状态正常。
|
2. 打开“API Center”,确认 API 服务和数据库状态正常。
|
||||||
3. 在 Reader Skill 区域选择目标 Agent,点击“复制安装指令”。
|
3. 在 Reader Skill 区域选择目标 Agent,点击“复制安装指令”。
|
||||||
4. 把指令粘贴到对应 Agent 的 Skill/配置目录;应用会根据本机路径生成适合 Codex、Claude Code、OpenClaw 或通用 Agent 的说明。
|
4. 把指令粘贴到对应 Agent 的 Skill/配置目录;应用会根据本机路径生成适合 Codex、Claude Code、OpenClaw 或通用 Agent 的说明。
|
||||||
5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置:
|
5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export WECHATEXPLORER_API_TOKEN="<YOUR_API_TOKEN>"
|
export TRACEMEMO_API_TOKEN="<YOUR_API_TOKEN>"
|
||||||
```
|
```
|
||||||
|
|
||||||
6. 先执行 health 检查,再读取数据端点。
|
6. 先执行 health 检查,再读取数据端点。
|
||||||
|
|
||||||
WechatExplorer 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
|
TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
|
||||||
|
|
||||||
## Agent 的读取顺序
|
## Agent 的读取顺序
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ WechatExplorer 不会自动把 Token 写进 Agent 配置。重新生成 Token
|
|||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:6131/api/v1/health
|
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"
|
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# WechatExplorer 2.1.9:Local HTTP API 鉴权迁移
|
# TraceMemo 2.1.9:Local HTTP API 鉴权迁移
|
||||||
|
|
||||||
2.1.9 为 Local HTTP API 增加 Bearer Token 鉴权。这是一次有意的兼容性变化:除健康检查外,数据接口不再接受裸请求。
|
2.1.9 为 Local HTTP API 增加 Bearer Token 鉴权。这是一次有意的兼容性变化:除健康检查外,数据接口不再接受裸请求。
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
- 2.1.9 中,相同请求必须携带 `Authorization: Bearer <TOKEN>`,否则返回 `401`;
|
- 2.1.9 中,相同请求必须携带 `Authorization: Bearer <TOKEN>`,否则返回 `401`;
|
||||||
- `GET /api/v1/health` 保持公开;
|
- `GET /api/v1/health` 保持公开;
|
||||||
- 升级后应用会生成并安全保存 Token,原有 API 启用状态、监听地址和端口设置保持不变;
|
- 升级后应用会生成并安全保存 Token,原有 API 启用状态、监听地址和端口设置保持不变;
|
||||||
- Token 可在 WechatExplorer → API Center 中显示、复制和重新生成;
|
- Token 可在 TraceMemo → API Center 中显示、复制和重新生成;
|
||||||
- Reader Skill、Codex、Claude Code、OpenClaw 和其他本地 Agent 需要在自己的环境中设置 `WECHATEXPLORER_API_TOKEN`。
|
- Reader Skill、Codex、Claude Code、OpenClaw 和其他本地 Agent 需要在自己的环境中设置 `WECHATEXPLORER_API_TOKEN`。
|
||||||
|
|
||||||
如果旧 Agent 无法访问,请先从 API Center 复制当前 Token,再确认每个非 health 请求都带有 Bearer header。完整规则见[API 安全](./api-security.md)。
|
如果旧 Agent 无法访问,请先从 API Center 复制当前 Token,再确认每个非 health 请求都带有 Bearer header。完整规则见[API 安全](./api-security.md)。
|
||||||
|
|||||||
@@ -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 helper:macOS 使用旧 `WechatExplorer` identity,helper 只在内存中解密并校验旧 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)。
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内容来自哪个会话和时间,并跳回原始消息检查上下文。
|
AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内容来自哪个会话和时间,并跳回原始消息检查上下文。
|
||||||
|
|
||||||
这让 WechatExplorer 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
|
这让 TraceMemo 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
|
||||||
|
|
||||||
## 三类来源信息
|
## 三类来源信息
|
||||||
|
|
||||||
|
|||||||
@@ -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、微信数据路径或聊天内容。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 开发、测试与构建
|
# 开发、测试与构建
|
||||||
|
|
||||||
本文面向希望参与 WechatExplorer 开发、验证文档或维护集成的贡献者。普通用户请从[第一次使用](../user-guide/getting-started.md)开始。
|
本文面向希望参与 TraceMemo 开发、验证文档或维护集成的贡献者。普通用户请从[第一次使用](../user-guide/getting-started.md)开始。
|
||||||
|
|
||||||
## 技术基线
|
## 技术基线
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||||
|
|
||||||
常用检查:
|
常用检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## 你什么时候会看到这些提示
|
## 你什么时候会看到这些提示
|
||||||
|
|
||||||
WechatExplorer 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
|
TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
|
||||||
|
|
||||||
## 推荐步骤
|
## 推荐步骤
|
||||||
|
|
||||||
1. 先启动 WechatExplorer,阅读连接页面显示的当前前置条件。
|
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
|
||||||
2. 确认微信数据目录指向当前账号。
|
2. 确认微信数据目录指向当前账号。
|
||||||
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
||||||
4. 返回应用重新检测账号、数据库和图片资源状态。
|
4. 返回应用重新检测账号、数据库和图片资源状态。
|
||||||
@@ -15,7 +15,7 @@ WechatExplorer 需要读取微信本地数据。macOS 会根据系统版本、
|
|||||||
|
|
||||||
## SIP 风险
|
## SIP 风险
|
||||||
|
|
||||||
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 WechatExplorer 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
|
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 TraceMemo 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
|
||||||
|
|
||||||
## 应用无法打开
|
## 应用无法打开
|
||||||
|
|
||||||
|
|||||||
@@ -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`:用户还没有完成数据库连接或对应服务未就绪。
|
||||||
|
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||||
@@ -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`:用户还没有完成数据库连接或对应服务未就绪。
|
|
||||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 第一次使用 WechatExplorer
|
# 第一次使用 TraceMemo
|
||||||
|
|
||||||
如果你刚下载 WechatExplorer,只需要完成一条主线:
|
如果你刚下载 TraceMemo,只需要完成一条主线:
|
||||||
|
|
||||||
> 安装应用 → 连接微信数据 → 确认聊天已加载 → 搜索或提问。
|
> 安装应用 → 连接微信数据 → 确认聊天已加载 → 搜索或提问。
|
||||||
|
|
||||||
@@ -8,29 +8,59 @@
|
|||||||
|
|
||||||
## 1. 开始前准备
|
## 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 服务。
|
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||||
|
|
||||||
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
||||||
|
|
||||||
## 2. 安装并启动
|
## 2. 安装并启动
|
||||||
|
|
||||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载对应系统的安装包。
|
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
|
||||||
2. Windows 使用 `-setup.exe` 安装;macOS 打开 `.dmg` 并将应用拖入“应用程序”。
|
|
||||||
3. 启动 WechatExplorer,进入“第一次使用”页面。
|
|
||||||
|
|
||||||
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 Silicon(M 系列、`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. 让应用读取微信数据
|
## 3. 让应用读取微信数据
|
||||||
|
|
||||||
第一次打开时,应用会引导你完成数据库连接。核心是两件事:找到当前微信数据目录,并取得对应账号的数据库密钥。
|
首次启动会自动进入“第一次使用”页面。页面会根据当前系统显示连接方式和注意事项:
|
||||||
|
|
||||||
1. 在连接页面确认微信数据目录。自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径后粘贴到页面。
|
<p align="center">
|
||||||
2. 第一次使用先点击“开始连接”,按页面提示准备连接组件和获取密钥;只有已经通过其他方式拿到密钥的高级用户才选择“手动连接”。
|
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
|
||||||
3. 如果自动获取要求微信处于特定登录状态,请按页面提示完成登录、授权或重新检测;不要只关闭微信窗口就认为已经退出。
|
</p>
|
||||||
4. 点击开始连接,等待数据库、账号和联系人检查完成。
|
|
||||||
|
通常按下面三步操作即可:
|
||||||
|
|
||||||
|
1. **确认微信数据目录**:自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径并在页面中修改。
|
||||||
|
2. **让微信停在登录页面**:如果微信已经登录,先退出当前微信账号,不只是关闭微信窗口。
|
||||||
|
3. **点击“开始连接”并按提示获取密钥**:软件准备好连接组件后会提示你登录微信;回到微信完成登录,再等待数据库、账号和联系人检查完成。
|
||||||
|
|
||||||
|
只有已经通过其他方式取得当前账号数据库密钥的高级用户,才需要选择“手动连接”。Windows 不需要关闭 SIP;macOS 是否需要额外授权或处理 SIP,以当前连接页面提示为准。
|
||||||
|
|
||||||
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
|
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
|
||||||
|
|
||||||
@@ -77,12 +107,12 @@ macOS 如果提示无法验证开发者,请按系统提示允许打开。自
|
|||||||
- [转写微信语音](./voice.md)
|
- [转写微信语音](./voice.md)
|
||||||
- [导出聊天档案](./export.md)
|
- [导出聊天档案](./export.md)
|
||||||
- [可选开启防撤回](./recall-protection.md)
|
- [可选开启防撤回](./recall-protection.md)
|
||||||
- [在微信里向 WechatExplorer 提问](../agent/agent-hub.md)
|
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||||
|
|
||||||
## 7. 想直接在微信里提问
|
## 7. 想直接在微信里提问
|
||||||
|
|
||||||
如果你希望直接在微信里向 WechatExplorer 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub:
|
如果你希望直接在微信里向 TraceMemo 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub:
|
||||||
|
|
||||||
1. 先完成上面的微信数据库连接,并确认“档案”里能看到聊天。
|
1. 先完成上面的微信数据库连接,并确认“档案”里能看到聊天。
|
||||||
2. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
|
2. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
如果你经常查同一批工作群、项目讨论或长期联系人,只靠每次临时翻聊天会越来越慢。Knowledge 会在本机建立一份可重复查找的索引,让“以前聊过什么”这类问题更容易跨会话、跨时间找到相关内容。
|
如果你经常查同一批工作群、项目讨论或长期联系人,只靠每次临时翻聊天会越来越慢。Knowledge 会在本机建立一份可重复查找的索引,让“以前聊过什么”这类问题更容易跨会话、跨时间找到相关内容。
|
||||||
|
|
||||||
它不是另一个聊天窗口,也不会替你修改微信原始数据库;它是 WechatExplorer 为当前账号维护的本地加速资料。
|
它不是另一个聊天窗口,也不会替你修改微信原始数据库;它是 TraceMemo 为当前账号维护的本地加速资料。
|
||||||
|
|
||||||
## 建立和同步
|
## 建立和同步
|
||||||
|
|
||||||
@@ -34,5 +34,5 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
|||||||
|
|
||||||
## 产品术语(可选)
|
## 产品术语(可选)
|
||||||
|
|
||||||
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 WechatExplorer 的前置知识。
|
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
|
||||||
|
|
||||||
|
|||||||
@@ -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。
|
- 最终用于总结的 Evidence。
|
||||||
|
|
||||||
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 WechatExplorer 控制,请查看你所选服务商的政策。
|
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 TraceMemo 控制,请查看你所选服务商的政策。
|
||||||
|
|
||||||
Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的日志和配置仍由你负责。
|
Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的日志和配置仍由你负责。
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
|
|||||||
|
|
||||||
外部 Agent 通过 Reader Skill 调用本机 API,按需读取联系人、会话或时间范围内的聊天;它不会因此获得数据库文件路径或任意文件系统权限。Agent 是否把读取结果再次发送给模型,取决于 Agent 本身及其配置。
|
外部 Agent 通过 Reader Skill 调用本机 API,按需读取联系人、会话或时间范围内的聊天;它不会因此获得数据库文件路径或任意文件系统权限。Agent 是否把读取结果再次发送给模型,取决于 Agent 本身及其配置。
|
||||||
|
|
||||||
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 WechatExplorer,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
|
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 TraceMemo,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
|
||||||
|
|
||||||
机器人收到的文字会先进入本机 Agent Hub;如果任务需要总结或自然语言理解,受控上下文可能发送给你配置的 AI Provider。机器人账号扫码登录、个人微信数据库连接和外部 Agent/API Token 是不同的边界,使用前请分别确认账号与权限。
|
机器人收到的文字会先进入本机 Agent Hub;如果任务需要总结或自然语言理解,受控上下文可能发送给你配置的 AI Provider。机器人账号扫码登录、个人微信数据库连接和外部 Agent/API Token 是不同的边界,使用前请分别确认账号与权限。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 防撤回
|
# 防撤回
|
||||||
|
|
||||||
防撤回是一个默认关闭的可选功能。开启后,WechatExplorer 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
||||||
|
|
||||||
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
||||||
2. 打开“设置 → 防撤回”。
|
2. 打开“设置 → 防撤回”。
|
||||||
3. 阅读性能和数据提示后,开启“防撤回”。
|
3. 阅读性能和数据提示后,开启“防撤回”。
|
||||||
4. 保持 WechatExplorer 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
||||||
|
|
||||||
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
||||||
|
|
||||||
@@ -18,19 +18,19 @@
|
|||||||
- 监听应用能够识别到的后续撤回变化;
|
- 监听应用能够识别到的后续撤回变化;
|
||||||
- 在本地保留必要的消息和撤回关系;
|
- 在本地保留必要的消息和撤回关系;
|
||||||
- 将已识别的原消息与撤回状态一起显示在档案中;
|
- 将已识别的原消息与撤回状态一起显示在档案中;
|
||||||
- 按微信账号隔离 WechatExplorer 保存的恢复记录。
|
- 按微信账号隔离 TraceMemo 保存的恢复记录。
|
||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
|
|
||||||
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
||||||
- WechatExplorer 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
||||||
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
||||||
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
||||||
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
||||||
|
|
||||||
## 数据写入与关闭边界
|
## 数据写入与关闭边界
|
||||||
|
|
||||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 WechatExplorer 的用户数据目录保存必要的本地恢复记录。
|
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
||||||
|
|
||||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 生成群聊日报和总结
|
# 生成群聊日报和总结
|
||||||
|
|
||||||
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 WechatExplorer 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
|
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 TraceMemo 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
|
||||||
|
|
||||||
## 报告适合做什么
|
## 报告适合做什么
|
||||||
|
|
||||||
|
|||||||
@@ -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 配置;
|
2. Agent 使用的是当前 Reader Skill,而不是旧的 MCP 配置;
|
||||||
3. 请求地址为 `http://127.0.0.1:6131`;
|
3. 请求地址为 `http://127.0.0.1:6131`;
|
||||||
4. 非 health 请求带有最新 `Authorization: Bearer <TOKEN>`;
|
4. 非 health 请求带有最新 `Authorization: Bearer <TOKEN>`;
|
||||||
@@ -76,4 +91,4 @@ Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
|
|||||||
|
|
||||||
## 防撤回没有保留消息
|
## 防撤回没有保留消息
|
||||||
|
|
||||||
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、WechatExplorer 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
|
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 语音转文字
|
# 语音转文字
|
||||||
|
|
||||||
WechatExplorer 可以把微信语音转换成可搜索的文字,适合你不想逐条播放、希望把语音内容带入后续查找或导出的场景。
|
TraceMemo 可以把微信语音转换成可搜索的文字,适合你不想逐条播放、希望把语音内容带入后续查找或导出的场景。
|
||||||
|
|
||||||
## 使用前准备
|
## 使用前准备
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
appId: com.wechatexplorer.app
|
appId: com.tracememo.app
|
||||||
productName: WechatExplorer
|
productName: TraceMemo
|
||||||
afterPack: scripts/after-pack.cjs
|
afterPack: scripts/after-pack.cjs
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
@@ -25,15 +25,15 @@ extraResources:
|
|||||||
to: resources
|
to: resources
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
- from: docs/skill/wechatexplorer-reader
|
- from: docs/skill/tracememo-reader
|
||||||
to: skill/wechatexplorer-reader
|
to: skill/tracememo-reader
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
win:
|
win:
|
||||||
icon: icon.ico
|
icon: icon.ico
|
||||||
# WCDB's Windows runtime checks the host executable name. The dev runtime is
|
# WCDB's Windows runtime checks the host executable name. The dev runtime is
|
||||||
# electron.exe, so keep the packaged executable compatible while preserving
|
# electron.exe, so keep the packaged executable compatible while using
|
||||||
# WechatExplorer as the product/shortcut name.
|
# TraceMemo as the product/shortcut name.
|
||||||
executableName: electron
|
executableName: electron
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
oneClick: false
|
||||||
@@ -46,10 +46,10 @@ mac:
|
|||||||
icon: icon.icns
|
icon: icon.icns
|
||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
extendInfo:
|
extendInfo:
|
||||||
# The bundled WCDB bridge accepts Electron as its internal host name. The
|
# The bundled WCDB bridge still uses Electron as its internal executable
|
||||||
# public app name and bundle identifier remain WechatExplorer-specific.
|
# compatibility name; the public product and bundle identity are TraceMemo.
|
||||||
CFBundleName: Electron
|
CFBundleName: Electron
|
||||||
CFBundleDisplayName: WechatExplorer
|
CFBundleDisplayName: TraceMemo
|
||||||
NSCameraUsageDescription: Application requests access to the device's camera.
|
NSCameraUsageDescription: Application requests access to the device's camera.
|
||||||
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||||
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||||
|
|||||||
+6
-7
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "wechatexplorer",
|
"name": "tracememo",
|
||||||
"version": "2.1.9",
|
"version": "2.2.0",
|
||||||
"packageManager": "pnpm@7.33.7",
|
"packageManager": "pnpm@7.33.7",
|
||||||
"description": "macOS / Windows 本地优先的微信聊天记录搜索与 AI 工作台",
|
"description": "macOS / Windows 本地优先、可追溯的 AI 微信知识与分析工作台",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"wechat",
|
"wechat",
|
||||||
"wechat chat",
|
"wechat chat",
|
||||||
@@ -49,16 +49,15 @@
|
|||||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
"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: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:native-services": "npm run build:wechat-connector",
|
||||||
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
"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: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: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",
|
"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": "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: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:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||||
@@ -133,4 +132,4 @@
|
|||||||
"ffmpeg-static"
|
"ffmpeg-static"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,17 @@
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
flex: 0 0 auto;
|
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-grid img,
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -523,7 +534,7 @@
|
|||||||
<div class="overview">{{OVERVIEW}}</div>
|
<div class="overview">{{OVERVIEW}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
@@ -583,7 +594,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
数据来源:TraceMemo · 微信群聊记录<br />
|
||||||
生成时间:{{GENERATED_AT}}<br />
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
{{FOOTER_NOTE}}
|
{{FOOTER_NOTE}}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -69,6 +69,17 @@
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
flex: 0 0 auto;
|
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-grid img,
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -695,7 +706,7 @@
|
|||||||
<h1>{{GROUP_NAME}}日报</h1>
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-headline">
|
<div class="hero-headline">
|
||||||
<b>{{HERO_HEADLINE}}</b>
|
<b>{{HERO_HEADLINE}}</b>
|
||||||
@@ -820,7 +831,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
数据来源:TraceMemo · 微信群聊记录<br />
|
||||||
生成时间:{{GENERATED_AT}}<br />
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
{{FOOTER_NOTE}}
|
{{FOOTER_NOTE}}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ function validateAsarRuntimeDependencies(runtimeResources) {
|
|||||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
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(
|
const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter(
|
||||||
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
|
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
|
||||||
)
|
)
|
||||||
@@ -104,9 +106,9 @@ function setPlistValue(plistPath, key, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateReaderSkillRuntime(runtimeResources) {
|
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)) {
|
if (!existsSync(skillPath)) {
|
||||||
throw new Error(`Missing bundled WechatExplorer Reader Skill: ${skillPath}`)
|
throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`)
|
||||||
}
|
}
|
||||||
return skillPath
|
return skillPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# WechatExplorer v2.1.9 Local HTTP API 手动验收脚本
|
# TraceMemo v2.2.0 Local HTTP API 手动验收脚本
|
||||||
# 仅用于 macOS Terminal;不会写入或输出真实 API Token。
|
# 仅用于 macOS Terminal;不会写入或输出真实 API Token。
|
||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:6131}"
|
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:6131}"
|
||||||
API_BASE_URL="${API_BASE_URL%/}"
|
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
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
PASS_COUNT=0
|
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)}"; }
|
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"; }
|
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"
|
printf 'API 地址: %s\n\n' "$API_BASE_URL"
|
||||||
read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN
|
read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN
|
||||||
printf '\n'
|
printf '\n'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const root = path.resolve(__dirname, '..')
|
const root = path.resolve(__dirname, '..')
|
||||||
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
|
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) =>
|
const escapeHtml = (value) =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
|
|||||||
@@ -26,15 +26,15 @@ new Function('exports', 'require', 'module', output)(moduleExports, require, {
|
|||||||
const { buildSkillInstallInstruction } = moduleExports
|
const { buildSkillInstallInstruction } = moduleExports
|
||||||
const local = {
|
const local = {
|
||||||
type: 'local',
|
type: 'local',
|
||||||
directoryPath: 'C:/skill/wechatexplorer-reader',
|
directoryPath: 'C:/skill/tracememo-reader',
|
||||||
skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md',
|
skillPath: 'C:/skill/tracememo-reader/SKILL.md',
|
||||||
version: 'v1.0'
|
version: 'v1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [target, expected] of [
|
for (const [target, expected] of [
|
||||||
['codex', 'Codex 项目或用户 Skill 目录'],
|
['codex', 'Codex 项目或用户 Skill 目录'],
|
||||||
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
|
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
|
||||||
['openclaw', '作为 WechatExplorer Reader Skill 安装'],
|
['openclaw', '作为 TraceMemo Reader Skill 安装'],
|
||||||
['generic', '读取并安装']
|
['generic', '读取并安装']
|
||||||
]) {
|
]) {
|
||||||
const text = buildSkillInstallInstruction({
|
const text = buildSkillInstallInstruction({
|
||||||
@@ -44,6 +44,7 @@ for (const [target, expected] of [
|
|||||||
})
|
})
|
||||||
assert.match(text, new RegExp(expected))
|
assert.match(text, new RegExp(expected))
|
||||||
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
|
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, /WECHATEXPLORER_API_TOKEN/)
|
||||||
assert.match(text, /Authorization: Bearer/)
|
assert.match(text, /Authorization: Bearer/)
|
||||||
assert.doesNotMatch(text, /mcpServers/)
|
assert.doesNotMatch(text, /mcpServers/)
|
||||||
|
|||||||
@@ -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
|
- QR-code login with a single persisted credential
|
||||||
- account discovery
|
- 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>
|
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
|
## 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.
|
||||||
|
|||||||
@@ -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(rootName string) (string, error) {
|
||||||
func AccountsDir() (string, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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.
|
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
|
||||||
@@ -159,13 +186,7 @@ func SaveCredentials(creds *Credentials) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadAllCredentials loads all saved account credentials.
|
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
|
||||||
func LoadAllCredentials() ([]*Credentials, error) {
|
|
||||||
dir, err := AccountsDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -191,6 +212,24 @@ func LoadAllCredentials() ([]*Credentials, error) {
|
|||||||
return result, nil
|
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.
|
// CredentialsPath returns the path for display purposes.
|
||||||
func CredentialsPath() (string, error) {
|
func CredentialsPath() (string, error) {
|
||||||
return AccountsDir()
|
return AccountsDir()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ilink
|
package ilink
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -34,3 +35,46 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
|||||||
t.Fatalf("old sync state still exists: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ type Monitor struct {
|
|||||||
|
|
||||||
// NewMonitor creates a new long-poll monitor.
|
// NewMonitor creates a new long-poll monitor.
|
||||||
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
||||||
home, err := os.UserHomeDir()
|
accountID := NormalizeAccountID(client.BotID())
|
||||||
|
accountsRoot, err := accountDirectoryForID(accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
accountID := NormalizeAccountID(client.BotID())
|
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
|
||||||
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
|
|
||||||
|
|
||||||
m := &Monitor{
|
m := &Monitor{
|
||||||
client: client,
|
client: client,
|
||||||
@@ -73,7 +73,7 @@ func (m *Monitor) Run(ctx context.Context) error {
|
|||||||
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
||||||
m.failures, maxConsecutiveFailures, backoff, err)
|
m.failures, maxConsecutiveFailures, backoff, err)
|
||||||
if m.failures == maxConsecutiveFailures {
|
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 {
|
select {
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
@@ -96,7 +96,7 @@ func (m *Monitor) Run(ctx context.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
// Sync buf already empty but still getting session expired:
|
// Sync buf already empty but still getting session expired:
|
||||||
// the bot token itself has expired. The user needs to re-login.
|
// 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 {
|
select {
|
||||||
case <-time.After(sessionExpiredBackoff):
|
case <-time.After(sessionExpiredBackoff):
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface TokenReadResult {
|
|||||||
|
|
||||||
export class ApiTokenStore {
|
export class ApiTokenStore {
|
||||||
private cachedToken: string | null = null
|
private cachedToken: string | null = null
|
||||||
|
private automaticGenerationBlockedReason: string | null = null
|
||||||
|
|
||||||
constructor(private readonly filePathOverride?: string) {}
|
constructor(private readonly filePathOverride?: string) {}
|
||||||
|
|
||||||
@@ -42,10 +43,18 @@ export class ApiTokenStore {
|
|||||||
available: true,
|
available: true,
|
||||||
hasToken: result.success && Boolean(result.token),
|
hasToken: result.success && Boolean(result.token),
|
||||||
maskedToken: MASKED_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 {
|
ensureToken(): ApiTokenActionResult {
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
return {
|
return {
|
||||||
@@ -59,6 +68,9 @@ export class ApiTokenStore {
|
|||||||
const current = this.read()
|
const current = this.read()
|
||||||
if (!current.success) return this.actionError(current.error)
|
if (!current.success) return this.actionError(current.error)
|
||||||
if (current.token) return this.actionSuccess()
|
if (current.token) return this.actionSuccess()
|
||||||
|
if (this.automaticGenerationBlockedReason) {
|
||||||
|
return this.actionError(this.automaticGenerationBlockedReason)
|
||||||
|
}
|
||||||
return this.persist(this.generateToken())
|
return this.persist(this.generateToken())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +82,9 @@ export class ApiTokenStore {
|
|||||||
|
|
||||||
rotateToken(): ApiTokenActionResult {
|
rotateToken(): ApiTokenActionResult {
|
||||||
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
|
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 {
|
getTokenForAuthentication(): string | null {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export class AppLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get logPath(): string {
|
get logPath(): string {
|
||||||
return path.join(this.logDir, 'wechatexplorer.log')
|
return path.join(this.logDir, 'tracememo.log')
|
||||||
}
|
}
|
||||||
|
|
||||||
private rotateIfNeeded(): void {
|
private rotateIfNeeded(): void {
|
||||||
|
|||||||
+389
-26
@@ -21,8 +21,10 @@ import { VideoAssetService } from './video-asset-service'
|
|||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
import { getImageExportAttempts } from '../shared/export-media'
|
import { getImageExportAttempts } from '../shared/export-media'
|
||||||
import { FileAssetService } from './file-asset-service'
|
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 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 jobs = new Set<string>()
|
||||||
const activeArchives = new Map<string, Archiver>()
|
const activeArchives = new Map<string, Archiver>()
|
||||||
@@ -42,6 +44,18 @@ const exportStamp = (): string => {
|
|||||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
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())}`
|
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()
|
const imageKeys = new ImageKeyConfigService()
|
||||||
|
|
||||||
export interface HtmlExportConversation {
|
export interface HtmlExportConversation {
|
||||||
@@ -250,6 +264,7 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
|
|||||||
'exportMediaUrl',
|
'exportMediaUrl',
|
||||||
'exportMediaType',
|
'exportMediaType',
|
||||||
'exportMediaName',
|
'exportMediaName',
|
||||||
|
'exportMediaQuality',
|
||||||
'exportAvatarUrl'
|
'exportAvatarUrl'
|
||||||
]
|
]
|
||||||
for (const key of preserveWhenMissing) {
|
for (const key of preserveWhenMissing) {
|
||||||
@@ -319,7 +334,8 @@ export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] {
|
|||||||
export async function readHtmlArchive(
|
export async function readHtmlArchive(
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
targets: ExportTarget[],
|
targets: ExportTarget[],
|
||||||
name: string
|
name: string,
|
||||||
|
allowTargetChanges = false
|
||||||
): Promise<HtmlExportArchive> {
|
): Promise<HtmlExportArchive> {
|
||||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||||
let source = ''
|
let source = ''
|
||||||
@@ -381,7 +397,7 @@ export async function readHtmlArchive(
|
|||||||
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
||||||
.map((conversation) => conversation.id)
|
.map((conversation) => conversation.id)
|
||||||
.sort()
|
.sort()
|
||||||
if (actualIds.join('|') !== expectedIds.join('|')) {
|
if (!allowTargetChanges && actualIds.join('|') !== expectedIds.join('|')) {
|
||||||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -461,6 +477,20 @@ export async function writeHtmlArchive(
|
|||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
const source = `${archiveDataPrefix}${JSON.stringify(archive)};\n`
|
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')
|
await fs.writeFile(temporaryPath, source, 'utf8')
|
||||||
try {
|
try {
|
||||||
await fs.rename(temporaryPath, dataPath)
|
await fs.rename(temporaryPath, dataPath)
|
||||||
@@ -622,19 +652,129 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
|||||||
].join('\n')
|
].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,
|
request: ExportRequest,
|
||||||
win: BrowserWindow,
|
win: BrowserWindow,
|
||||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
|
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
|
||||||
|
options: SingleExportOptions = {}
|
||||||
): Promise<ExportResult> {
|
): Promise<ExportResult> {
|
||||||
jobs.add(request.jobId)
|
const manageJob = options.manageJob !== false
|
||||||
const send = (p: ExportJobProgress): void => {
|
if (manageJob) jobs.add(request.jobId)
|
||||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
const send =
|
||||||
}
|
options.sendProgress ||
|
||||||
|
((p: ExportJobProgress): void => {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
const targets = request.targets || []
|
const targets = (request.targets || []).map((target) => ({
|
||||||
if (targets.length < 1 || targets.length > 5) {
|
...target,
|
||||||
throw new Error('一次导出必须选择 1 到 5 个聊天')
|
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) {
|
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
|
||||||
throw new Error('导出聊天不能重复')
|
throw new Error('导出聊天不能重复')
|
||||||
@@ -652,6 +792,25 @@ export async function runExport(
|
|||||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
||||||
return { success: false, error: '已取消' }
|
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 = (
|
const targetMessages = (
|
||||||
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
|
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
|
||||||
).filter((message) => request.kinds.includes(kindOf(message)))
|
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||||
@@ -682,8 +841,12 @@ export async function runExport(
|
|||||||
return left.messageOrder - right.messageOrder
|
return left.messageOrder - right.messageOrder
|
||||||
})
|
})
|
||||||
.map((entry) => entry.message)
|
.map((entry) => entry.message)
|
||||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
const selfInfo =
|
||||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
options.selfInfo !== undefined
|
||||||
|
? options.selfInfo
|
||||||
|
: await chat
|
||||||
|
.getSelfAccountInfoAsync()
|
||||||
|
.then((value) => (value ? mergeCachedSelfInfo(value.accountRoot, value) : null))
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
const isUsableSelfName = (value: string | undefined): value is string => {
|
const isUsableSelfName = (value: string | undefined): value is string => {
|
||||||
const name = String(value || '').trim()
|
const name = String(value || '').trim()
|
||||||
@@ -706,6 +869,7 @@ export async function runExport(
|
|||||||
message.exportMediaUrl = undefined
|
message.exportMediaUrl = undefined
|
||||||
message.exportMediaType = undefined
|
message.exportMediaType = undefined
|
||||||
message.exportMediaName = undefined
|
message.exportMediaName = undefined
|
||||||
|
message.exportMediaQuality = undefined
|
||||||
message.exportMediaError = undefined
|
message.exportMediaError = undefined
|
||||||
message.voiceDataUrl = undefined
|
message.voiceDataUrl = undefined
|
||||||
message.voiceTranscript = undefined
|
message.voiceTranscript = undefined
|
||||||
@@ -756,25 +920,37 @@ export async function runExport(
|
|||||||
total: messages.length,
|
total: messages.length,
|
||||||
percent: request.format === 'html' ? 18 : 20
|
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 ext = request.format === 'markdown' ? 'md' : request.format
|
||||||
const outputFolder =
|
const outputFolder =
|
||||||
request.format === 'html'
|
request.format === 'html'
|
||||||
? safeFilePart(request.outputName)
|
? options.outputFolderName || safeFilePart(request.outputName)
|
||||||
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||||
|
const root = options.outputRoot || (await resolveDefaultExportRoot(outputFolder))
|
||||||
|
await fs.mkdir(root, { recursive: true })
|
||||||
const outputDir = join(root, outputFolder)
|
const outputDir = join(root, outputFolder)
|
||||||
const outputPath =
|
const outputPath =
|
||||||
request.format === 'html'
|
request.format === 'html'
|
||||||
? join(outputDir, 'index.html')
|
? join(outputDir, 'index.html')
|
||||||
: join(root, `${outputFolder}.${ext}`)
|
: join(root, `${outputFolder}.${ext}`)
|
||||||
if (request.format === 'html') {
|
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(
|
const previousMessagesByKey = new Map(
|
||||||
previousArchive.messages.map((message) => [exportMessageKey(message), message])
|
previousMessages.map((message) => [exportMessageKey(message), message])
|
||||||
)
|
)
|
||||||
const latestPreviousAvatarUrls = new Map<string, string>()
|
const latestPreviousAvatarUrls = new Map<string, string>()
|
||||||
for (const message of previousArchive.messages) {
|
for (const message of previousMessages) {
|
||||||
if (!message.exportAvatarUrl) continue
|
if (!message.exportAvatarUrl) continue
|
||||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||||
latestPreviousAvatarUrls.set(
|
latestPreviousAvatarUrls.set(
|
||||||
@@ -1063,15 +1239,20 @@ export async function runExport(
|
|||||||
: message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
: message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||||
? 'file'
|
? 'file'
|
||||||
: null
|
: null
|
||||||
|
const reusableImageQuality =
|
||||||
|
previous?.exportMediaQuality === 'original' ||
|
||||||
|
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
|
||||||
if (
|
if (
|
||||||
reusableMediaType &&
|
reusableMediaType &&
|
||||||
previous?.exportMediaUrl &&
|
previous?.exportMediaUrl &&
|
||||||
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
||||||
|
(reusableMediaType !== 'image' || reusableImageQuality) &&
|
||||||
(await resourceExists(previous.exportMediaUrl))
|
(await resourceExists(previous.exportMediaUrl))
|
||||||
) {
|
) {
|
||||||
message.exportMediaUrl = previous.exportMediaUrl
|
message.exportMediaUrl = previous.exportMediaUrl
|
||||||
message.exportMediaType = reusableMediaType
|
message.exportMediaType = reusableMediaType
|
||||||
message.exportMediaName = previous.exportMediaName
|
message.exportMediaName = previous.exportMediaName
|
||||||
|
message.exportMediaQuality = previous.exportMediaQuality
|
||||||
send({
|
send({
|
||||||
jobId: request.jobId,
|
jobId: request.jobId,
|
||||||
phase: 'media',
|
phase: 'media',
|
||||||
@@ -1087,7 +1268,6 @@ export async function runExport(
|
|||||||
} else {
|
} else {
|
||||||
let fileFound = false
|
let fileFound = false
|
||||||
let decryptedImage: { data: string; filePath: string } | null = null
|
let decryptedImage: { data: string; filePath: string } | null = null
|
||||||
let usedFallback = false
|
|
||||||
for (const attempt of getImageExportAttempts(request)) {
|
for (const attempt of getImageExportAttempts(request)) {
|
||||||
const file = await imageService.findImageFileAsync(
|
const file = await imageService.findImageFileAsync(
|
||||||
message.contentData.md5,
|
message.contentData.md5,
|
||||||
@@ -1116,7 +1296,6 @@ export async function runExport(
|
|||||||
}
|
}
|
||||||
if (!decrypted) continue
|
if (!decrypted) continue
|
||||||
decryptedImage = decrypted
|
decryptedImage = decrypted
|
||||||
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
||||||
@@ -1129,7 +1308,8 @@ export async function runExport(
|
|||||||
}
|
}
|
||||||
message.exportMediaUrl = mediaUrl
|
message.exportMediaUrl = mediaUrl
|
||||||
message.exportMediaType = 'image'
|
message.exportMediaType = 'image'
|
||||||
if (usedFallback) {
|
message.exportMediaQuality = imageFileQuality(decryptedImage!.filePath)
|
||||||
|
if (request.preferOriginal !== false && message.exportMediaQuality === 'thumbnail') {
|
||||||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||||||
}
|
}
|
||||||
} else if (!fileFound) {
|
} else if (!fileFound) {
|
||||||
@@ -1152,11 +1332,10 @@ export async function runExport(
|
|||||||
].filter((value): value is string => Boolean(value))
|
].filter((value): value is string => Boolean(value))
|
||||||
if (!videoService) {
|
if (!videoService) {
|
||||||
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
||||||
} else if (hashes.length === 0) {
|
|
||||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
|
||||||
} else {
|
} else {
|
||||||
const resolved = await videoService.resolve(hashes, {
|
const resolved = await videoService.resolve(hashes, {
|
||||||
createTime: message.createTime,
|
createTime: message.createTime,
|
||||||
|
byteLength: message.contentData.byteLength,
|
||||||
duration: message.contentData.duration,
|
duration: message.contentData.duration,
|
||||||
width: message.contentData.width,
|
width: message.contentData.width,
|
||||||
height: message.contentData.height
|
height: message.contentData.height
|
||||||
@@ -1226,7 +1405,7 @@ export async function runExport(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const mergedMessages = mergeHtmlArchiveMessages(
|
const mergedMessages = mergeHtmlArchiveMessages(
|
||||||
previousArchive.messages,
|
previousMessages,
|
||||||
messages,
|
messages,
|
||||||
'',
|
'',
|
||||||
targets.map((target) => target.userMd5)
|
targets.map((target) => target.userMd5)
|
||||||
@@ -1313,10 +1492,194 @@ export async function runExport(
|
|||||||
}
|
}
|
||||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||||
return { success: false, 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 {
|
} finally {
|
||||||
jobs.delete(request.jobId)
|
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 {
|
export function cancelExport(jobId: string): void {
|
||||||
jobs.delete(jobId)
|
jobs.delete(jobId)
|
||||||
activeArchives.get(jobId)?.abort()
|
activeArchives.get(jobId)?.abort()
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
GroupReportExportResult,
|
GroupReportExportResult,
|
||||||
GroupReportMetadata,
|
GroupReportMetadata,
|
||||||
ReportHeat,
|
ReportHeat,
|
||||||
ReportSectionMeta
|
ReportSectionMeta,
|
||||||
|
selectHeroParticipantNames
|
||||||
} from '../shared/group-report'
|
} from '../shared/group-report'
|
||||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||||
import { imageInsightService } from './services/image-insight-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)) {
|
if (/^https?:\/\//i.test(source)) {
|
||||||
const response = await fetch(source, {
|
const response = await fetch(source, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 WechatExplorer',
|
'User-Agent': 'Mozilla/5.0 TraceMemo',
|
||||||
Referer: 'https://weixin.qq.com/'
|
Referer: 'https://weixin.qq.com/'
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(8000)
|
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 avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||||
|
|
||||||
const heroNames = metadata.heroParticipants.slice(0, 4)
|
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||||
while (heroNames.length < 4) heroNames.push(metadata.groupName)
|
|
||||||
const heroAvatars = heroNames
|
const heroAvatars = heroNames
|
||||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||||
.join('')
|
.join('')
|
||||||
|
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
||||||
|
|
||||||
const topicCards = report.topics
|
const topicCards = report.topics
|
||||||
.map(
|
.map(
|
||||||
@@ -218,10 +219,18 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
if (insight) {
|
if (insight) {
|
||||||
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
|
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
|
||||||
// 走 ImageDecryptService.findImageFile + decryptImageToBase64
|
// 走 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) {
|
if (decryptService) {
|
||||||
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
|
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),
|
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||||
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||||
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
|
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
|
||||||
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
|
OVERVIEW: escapeHtml(
|
||||||
|
report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'
|
||||||
|
),
|
||||||
// v2 模板使用的 hero-*
|
// v2 模板使用的 hero-*
|
||||||
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
||||||
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
|
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_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
|
||||||
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
|
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
|
||||||
HERO_AVATARS: heroAvatars,
|
HERO_AVATARS: heroAvatars,
|
||||||
|
HERO_AVATAR_CLASS: heroAvatarClass,
|
||||||
MESSAGE_COUNT: String(summaryStats.messageCount),
|
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||||
ACTIVE_USERS: String(summaryStats.activeUsers),
|
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
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),
|
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
|
||||||
RESOURCE_ITEMS: resourceItems,
|
RESOURCE_ITEMS: resourceItems,
|
||||||
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
|
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,
|
IMPORTANT_MESSAGES: importantMessages,
|
||||||
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
||||||
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
||||||
QUOTE_BLOCKS: quoteBlocks,
|
QUOTE_BLOCKS: quoteBlocks,
|
||||||
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
|
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_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||||
TODO_CARDS: todoCards,
|
TODO_CARDS: todoCards,
|
||||||
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
|
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_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
|
||||||
VOICE_CARDS: voiceCards,
|
VOICE_CARDS: voiceCards,
|
||||||
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
|
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,
|
VOICE_RANK_CARDS: voiceRankCards,
|
||||||
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
|
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
|
||||||
BADGE_CARDS: badgeCards,
|
BADGE_CARDS: badgeCards,
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
ready: isReady(),
|
ready: isReady(),
|
||||||
service: 'WechatExplorer Reader',
|
service: 'TraceMemo Reader',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
})
|
})
|
||||||
@@ -186,7 +186,7 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/contact': ({ res, url }) => {
|
'/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 filter = url.searchParams.get('filter') || undefined
|
||||||
const type = url.searchParams.get('type') || undefined
|
const type = url.searchParams.get('type') || undefined
|
||||||
let contacts = listContacts(filter)
|
let contacts = listContacts(filter)
|
||||||
@@ -197,7 +197,7 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/chatroom': ({ res, url }) => {
|
'/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') || ''
|
const keyword = url.searchParams.get('keyword') || ''
|
||||||
let groups = listContacts().filter((c) => c.type === 'group')
|
let groups = listContacts().filter((c) => c.type === 'group')
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
@@ -212,14 +212,14 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/recent_chat': ({ res, url }) => {
|
'/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 limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||||
const items = listRecentChat(limit)
|
const items = listRecentChat(limit)
|
||||||
sendJson(res, 200, { count: items.length, items })
|
sendJson(res, 200, { count: items.length, items })
|
||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/chatlog': ({ res, url }) => {
|
'/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')
|
const talker = url.searchParams.get('talker')
|
||||||
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/group_snapshot': ({ res, url }) => {
|
'/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')
|
const md5 = url.searchParams.get('md5')
|
||||||
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||||
const snapshot = getGroupSnapshot(md5)
|
const snapshot = getGroupSnapshot(md5)
|
||||||
@@ -266,7 +266,7 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/resolve': ({ res, url }) => {
|
'/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')
|
const q = url.searchParams.get('q')
|
||||||
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||||
const contact = resolveMd5(q)
|
const contact = resolveMd5(q)
|
||||||
@@ -276,7 +276,7 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
|
|
||||||
'/api/v1/report': async ({ req, res, body }) => {
|
'/api/v1/report': async ({ req, res, body }) => {
|
||||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
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()) {
|
if (typeof body !== 'string' || !body.trim()) {
|
||||||
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
|
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 }) => {
|
'/api/v1/agent/group-report': async ({ req, res, body }) => {
|
||||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
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' }
|
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
||||||
try {
|
try {
|
||||||
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import { execFile } from 'child_process'
|
|||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
import ffmpegStaticPath from 'ffmpeg-static'
|
import ffmpegStaticPath from 'ffmpeg-static'
|
||||||
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
|
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
|
||||||
|
import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
|
||||||
import { loadSettings } from './services/settings-store'
|
import { loadSettings } from './services/settings-store'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
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 => {
|
const imageDecryptLog = (...args: unknown[]): void => {
|
||||||
if (imageDecryptDebugEnabled) console.log(...args)
|
if (imageDecryptDebugEnabled) console.log(...args)
|
||||||
}
|
}
|
||||||
@@ -208,6 +211,13 @@ function isThumbnailName(fileName) {
|
|||||||
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
|
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) {
|
function buildPreferredDatNames(baseName) {
|
||||||
const base = normalizeDatBase(baseName)
|
const base = normalizeDatBase(baseName)
|
||||||
if (!base) return []
|
if (!base) return []
|
||||||
@@ -436,8 +446,8 @@ function collectCandidates(datPath, allowThumbnail) {
|
|||||||
.map((name) => path.join(directory, name))
|
.map((name) => path.join(directory, name))
|
||||||
.filter((candidate) => fs.existsSync(candidate))
|
.filter((candidate) => fs.existsSync(candidate))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const thumbnailOrder = Number(isThumbnailName(path.basename(left))) - Number(isThumbnailName(path.basename(right)))
|
const qualityOrder = imageQualityRank(path.basename(right)) - imageQualityRank(path.basename(left))
|
||||||
return thumbnailOrder || fs.statSync(right).size - fs.statSync(left).size
|
return qualityOrder || fs.statSync(right).size - fs.statSync(left).size
|
||||||
})
|
})
|
||||||
return Array.from(new Set(candidates.concat(siblings)))
|
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 {
|
findImageFile(md5?: string, imageDatName?: string, options?: ImageFindOptions): string | null {
|
||||||
const allowThumbnail = options?.allowThumbnail !== false
|
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)) {
|
if (!existsSync(attachDir)) {
|
||||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||||
return rememberPath(
|
return rememberPath(
|
||||||
@@ -1824,17 +1834,17 @@ export class ImageDecryptService {
|
|||||||
.sort((left, right) => right.size - left.size)
|
.sort((left, right) => right.size - left.size)
|
||||||
|
|
||||||
const thumbnail = toSized(
|
const thumbnail = toSized(
|
||||||
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
paths.filter((candidate) => imageFileQuality(candidate) === 'thumbnail')
|
||||||
)
|
)
|
||||||
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||||
const nonThumb = toSized(
|
const allowed = toSized(paths)
|
||||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
|
||||||
)
|
.sort(
|
||||||
if (nonThumb[0]) return nonThumb[0].candidate
|
(left, right) =>
|
||||||
if (!allowThumbnail) return null
|
imageQualityRank(imageFileQuality(right.candidate)) -
|
||||||
|
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
|
||||||
const existing = toSized(paths)
|
)
|
||||||
return existing[0]?.candidate || null
|
return allowed[0]?.candidate || null
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLargestExistingPathAsync(
|
private async getLargestExistingPathAsync(
|
||||||
@@ -1858,17 +1868,21 @@ export class ImageDecryptService {
|
|||||||
.sort((left, right) => right.size - left.size)
|
.sort((left, right) => right.size - left.size)
|
||||||
|
|
||||||
if (preferThumbnail) {
|
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
|
if (thumbnail) return thumbnail.candidate
|
||||||
}
|
}
|
||||||
const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate)))
|
const allowed = sized
|
||||||
if (nonThumbnail) return nonThumbnail.candidate
|
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
|
||||||
return allowThumbnail ? sized[0]?.candidate || null : null
|
.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 {
|
private isThumbnailName(fileName: string): boolean {
|
||||||
const lower = fileName.toLowerCase()
|
return imageFileQuality(fileName) === 'thumbnail'
|
||||||
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isThumbnailFile(filePath: string): boolean {
|
isThumbnailFile(filePath: string): boolean {
|
||||||
|
|||||||
+160
-29
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
isLegacyMigrationHelper,
|
||||||
|
isUserDataIsolated,
|
||||||
|
roots as appDataRoots
|
||||||
|
} from './app-data-bootstrap'
|
||||||
import './preload-env'
|
import './preload-env'
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
@@ -97,7 +102,7 @@ import { agentHubService } from './services/agent-hub-service'
|
|||||||
import { appLogger } from './app-logger'
|
import { appLogger } from './app-logger'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import { appUpdateService } from './services/app-update-service'
|
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 type { CacheClearScope } from './services/cache-service'
|
||||||
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||||
import { VideoAssetService } from './video-asset-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 { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition'
|
||||||
import type { AiSearchPipelineRequest } from '../shared/ai-search'
|
import type { AiSearchPipelineRequest } from '../shared/ai-search'
|
||||||
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
|
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 { KnowledgeSearchService } from './knowledge/knowledge-search-service'
|
||||||
import { AiSearchPipelineService } from './services/ai-search-pipeline-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.
|
// 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
|
// 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 =
|
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||||
null
|
null
|
||||||
let appShutdownRequested = false
|
let appShutdownRequested = false
|
||||||
|
let isQuitting = false
|
||||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||||
const TRAY_MODE =
|
const TRAY_MODE =
|
||||||
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
||||||
@@ -416,11 +417,48 @@ function createWindow(): void {
|
|||||||
sandbox: false
|
sandbox: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
let closePromptInFlight = false
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
mainWindow.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) => {
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
shell.openExternal(details.url)
|
shell.openExternal(details.url)
|
||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
@@ -438,6 +476,74 @@ function createWindow(): void {
|
|||||||
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
||||||
// 某些 API 只能在此事件发生后使用
|
// 某些 API 只能在此事件发生后使用
|
||||||
app.whenReady().then(async () => {
|
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({
|
voiceRecognition = new VoiceRecognitionUseCase({
|
||||||
modelRoot: join(app.getPath('userData'), 'models', 'sensevoice-small-int8'),
|
modelRoot: join(app.getPath('userData'), 'models', 'sensevoice-small-int8'),
|
||||||
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
|
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
|
||||||
@@ -474,11 +580,11 @@ app.whenReady().then(async () => {
|
|||||||
return new Response('Media unavailable', { status: 500 })
|
return new Response('Media unavailable', { status: 500 })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
console.log(`TraceMemo main build: ${BUILD_MARK}`)
|
||||||
appLogger.write({
|
appLogger.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
scope: 'lifecycle',
|
scope: 'lifecycle',
|
||||||
message: 'WechatExplorer 启动',
|
message: 'TraceMemo 启动',
|
||||||
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
||||||
})
|
})
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
@@ -506,9 +612,16 @@ app.whenReady().then(async () => {
|
|||||||
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||||
console.log('[WCDB4] async bootstrap complete')
|
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
|
// 设置应用程序用户模型 ID
|
||||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
electronApp.setAppUserModelId('com.tracememo.app')
|
||||||
|
|
||||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
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:download', () => appUpdateService.download())
|
||||||
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
||||||
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
||||||
|
ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory())
|
||||||
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
||||||
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'knowledge', 'all']
|
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'knowledge', 'all']
|
||||||
if (!allowedScopes.includes(scope)) return getCacheSummary()
|
if (!allowedScopes.includes(scope)) return getCacheSummary()
|
||||||
@@ -625,7 +739,16 @@ app.whenReady().then(async () => {
|
|||||||
return { success: true, monitoring }
|
return { success: true, monitoring }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to init DB:', 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 {
|
} finally {
|
||||||
dbInitInFlight = null
|
dbInitInFlight = null
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1216,10 @@ app.whenReady().then(async () => {
|
|||||||
return voiceRecognition.recognize(reference)
|
return voiceRecognition.recognize(reference)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('voice:getTranscriptSnapshot', (_, reference: VoiceMessageReference) => {
|
||||||
|
return voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' as const }
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('voice:getBatchPreflight', (_, request: VoiceBatchRequest) => {
|
ipcMain.handle('voice:getBatchPreflight', (_, request: VoiceBatchRequest) => {
|
||||||
if (!voiceBatchService) throw new Error('Voice recognition is not initialized')
|
if (!voiceBatchService) throw new Error('Voice recognition is not initialized')
|
||||||
return voiceBatchService.preflight(request)
|
return voiceBatchService.preflight(request)
|
||||||
@@ -1339,7 +1466,13 @@ app.whenReady().then(async () => {
|
|||||||
async (
|
async (
|
||||||
_,
|
_,
|
||||||
hashes: string[],
|
hashes: string[],
|
||||||
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
options?: {
|
||||||
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!videoAssetService) {
|
if (!videoAssetService) {
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
@@ -1541,10 +1674,8 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
await agentHubService.start(settings)
|
await agentHubService.start(settings)
|
||||||
|
|
||||||
if (TRAY_MODE) {
|
setupTray()
|
||||||
app.dock?.hide()
|
if (TRAY_MODE) app.dock?.hide()
|
||||||
setupTray()
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
||||||
@@ -1568,6 +1699,7 @@ let quitCleanupComplete = false
|
|||||||
|
|
||||||
app.on('before-quit', (event) => {
|
app.on('before-quit', (event) => {
|
||||||
if (quitCleanupComplete) return
|
if (quitCleanupComplete) return
|
||||||
|
isQuitting = true
|
||||||
appShutdownRequested = true
|
appShutdownRequested = true
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (quitCleanupStarted) return
|
if (quitCleanupStarted) return
|
||||||
@@ -1607,7 +1739,7 @@ app.on('before-quit', (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function showMainWindow(): void {
|
function showMainWindow(): void {
|
||||||
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
|
if (process.platform === 'darwin') app.dock?.show().catch(() => undefined)
|
||||||
const wins = BrowserWindow.getAllWindows()
|
const wins = BrowserWindow.getAllWindows()
|
||||||
if (wins.length === 0) {
|
if (wins.length === 0) {
|
||||||
createWindow()
|
createWindow()
|
||||||
@@ -1625,13 +1757,9 @@ function buildTrayMenu(): Menu {
|
|||||||
label: '打开主窗口',
|
label: '打开主窗口',
|
||||||
click: () => showMainWindow()
|
click: () => showMainWindow()
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'API 状态',
|
|
||||||
click: () => showMainWindow()
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: '退出 WechatExplorer',
|
label: '退出 TraceMemo',
|
||||||
click: () => {
|
click: () => {
|
||||||
tray?.destroy()
|
tray?.destroy()
|
||||||
tray = null
|
tray = null
|
||||||
@@ -1650,9 +1778,12 @@ function setupTray(): void {
|
|||||||
? nativeImage.createEmpty()
|
? nativeImage.createEmpty()
|
||||||
: image.resize({ width: traySize, height: traySize, quality: 'best' })
|
: image.resize({ width: traySize, height: traySize, quality: 'best' })
|
||||||
tray = new Tray(trayImage)
|
tray = new Tray(trayImage)
|
||||||
tray.setToolTip('WechatExplorer')
|
tray.setToolTip('TraceMemo')
|
||||||
tray.setContextMenu(buildTrayMenu())
|
// 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('click', () => showMainWindow())
|
||||||
|
tray.on('right-click', () => tray?.popUpContextMenu(buildTrayMenu()))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Tray] Failed to create tray:', error)
|
console.warn('[Tray] Failed to create tray:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-356
@@ -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 { join, dirname, delimiter } from 'path'
|
||||||
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
||||||
import { execFile, spawn } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
@@ -12,14 +9,19 @@ import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
|
|||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
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 =
|
type DbKeyPollResult =
|
||||||
| { status: 'success'; key: string; loginRequiredDetected: boolean }
|
| { status: 'success'; key: string; loginRequiredDetected: boolean }
|
||||||
| { status: 'process-ended'; loginRequiredDetected: boolean }
|
| { status: 'process-ended'; loginRequiredDetected: boolean }
|
||||||
| { status: 'timeout'; loginRequiredDetected: boolean }
|
| { status: 'timeout'; loginRequiredDetected: boolean }
|
||||||
|
|
||||||
export class KeyService {
|
export class KeyService {
|
||||||
private readonly isMac = process.platform === 'darwin'
|
|
||||||
private koffi: any = null
|
private koffi: any = null
|
||||||
private lib: any = null
|
private lib: any = null
|
||||||
private initialized = false
|
private initialized = false
|
||||||
@@ -28,19 +30,15 @@ export class KeyService {
|
|||||||
private getStatusMessage: any = null
|
private getStatusMessage: any = null
|
||||||
private cleanupHook: any = null
|
private cleanupHook: any = null
|
||||||
private getLastErrorMsg: any = null
|
private getLastErrorMsg: any = null
|
||||||
private getImageKeyDll: any = null
|
|
||||||
private lastLoadError = ''
|
private lastLoadError = ''
|
||||||
|
|
||||||
// Win32 APIs
|
// Win32 APIs
|
||||||
private kernel32: any = null
|
private kernel32: any = null
|
||||||
private user32: any = null
|
private user32: any = null
|
||||||
private advapi32: any = null
|
|
||||||
|
|
||||||
// Kernel32
|
// Kernel32
|
||||||
private OpenProcess: any = null
|
private OpenProcess: any = null
|
||||||
private CloseHandle: any = null
|
private CloseHandle: any = null
|
||||||
private TerminateProcess: any = null
|
|
||||||
private QueryFullProcessImageNameW: any = null
|
|
||||||
|
|
||||||
// User32
|
// User32
|
||||||
private EnumWindows: any = null
|
private EnumWindows: any = null
|
||||||
@@ -50,22 +48,9 @@ export class KeyService {
|
|||||||
private GetWindowThreadProcessId: any = null
|
private GetWindowThreadProcessId: any = null
|
||||||
private IsWindowVisible: any = null
|
private IsWindowVisible: any = null
|
||||||
private EnumChildWindows: any = null
|
private EnumChildWindows: any = null
|
||||||
private PostMessageW: any = null
|
|
||||||
private WNDENUMPROC_PTR: any = null
|
private WNDENUMPROC_PTR: any = null
|
||||||
|
|
||||||
// Advapi32
|
|
||||||
private RegOpenKeyExW: any = null
|
|
||||||
private RegQueryValueExW: any = null
|
|
||||||
private RegCloseKey: any = null
|
|
||||||
|
|
||||||
// Constants
|
// 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 readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000
|
||||||
|
|
||||||
private getResourceRoots(): string[] {
|
private getResourceRoots(): string[] {
|
||||||
@@ -156,11 +141,11 @@ export class KeyService {
|
|||||||
this.lib = this.koffi.load(dllPath)
|
this.lib = this.koffi.load(dllPath)
|
||||||
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
|
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
|
||||||
this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)')
|
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.cleanupHook = this.lib.func('bool CleanupHook()')
|
||||||
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
|
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
|
||||||
this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)')
|
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -186,8 +171,6 @@ export class KeyService {
|
|||||||
this.kernel32 = this.koffi.load('kernel32.dll')
|
this.kernel32 = this.koffi.load('kernel32.dll')
|
||||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
|
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
|
||||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
|
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
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -211,12 +194,26 @@ export class KeyService {
|
|||||||
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
|
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
|
||||||
|
|
||||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
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.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', [
|
||||||
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
'void*',
|
||||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
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.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||||
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', [
|
||||||
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')])
|
'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*'])
|
this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*'])
|
||||||
|
|
||||||
return true
|
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 {
|
private decodeCString(ptr: any): string {
|
||||||
try {
|
try {
|
||||||
if (typeof ptr === 'string') return ptr
|
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[]> {
|
private async findPidsByImageName(imageName: string): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
|
const { stdout } = await execFileAsync('tasklist', [
|
||||||
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
'/FI',
|
||||||
|
`IMAGENAME eq ${imageName}`,
|
||||||
|
'/FO',
|
||||||
|
'CSV',
|
||||||
|
'/NH'
|
||||||
|
])
|
||||||
|
const lines = stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
const pids: number[] = []
|
const pids: number[] = []
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('INFO:')) continue
|
if (line.startsWith('INFO:')) continue
|
||||||
@@ -418,7 +291,7 @@ export class KeyService {
|
|||||||
const fallbackPid = await this.waitForWeChatWindow(250)
|
const fallbackPid = await this.waitForWeChatWindow(250)
|
||||||
if (fallbackPid) return fallbackPid
|
if (fallbackPid) return fallbackPid
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -428,10 +301,10 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async pollDbKeyFromHook(
|
private async pollDbKeyFromHook(
|
||||||
pid: number,
|
pid: number,
|
||||||
deadline: number,
|
deadline: number,
|
||||||
logs: string[],
|
logs: string[],
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<DbKeyPollResult> {
|
): Promise<DbKeyPollResult> {
|
||||||
const keyBuffer = Buffer.alloc(128)
|
const keyBuffer = Buffer.alloc(128)
|
||||||
let loginRequiredDetected = false
|
let loginRequiredDetected = false
|
||||||
@@ -441,7 +314,7 @@ export class KeyService {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now >= nextProcessCheckAt) {
|
if (now >= nextProcessCheckAt) {
|
||||||
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
|
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
|
||||||
if (!await this.isWeChatPidActive(pid)) {
|
if (!(await this.isWeChatPidActive(pid))) {
|
||||||
return { status: 'process-ended', loginRequiredDetected }
|
return { status: 'process-ended', loginRequiredDetected }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,13 +350,17 @@ export class KeyService {
|
|||||||
private cleanupDbKeyHook(): void {
|
private cleanupDbKeyHook(): void {
|
||||||
try {
|
try {
|
||||||
this.cleanupHook()
|
this.cleanupHook()
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildInitHookError(): string {
|
private buildInitHookError(): string {
|
||||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||||
if (error) {
|
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 '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||||
}
|
}
|
||||||
return error
|
return error
|
||||||
@@ -491,13 +368,17 @@ export class KeyService {
|
|||||||
|
|
||||||
const statusBuffer = Buffer.alloc(256)
|
const statusBuffer = Buffer.alloc(256)
|
||||||
const levelOut = [0]
|
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)
|
? this.decodeUtf8(statusBuffer)
|
||||||
: ''
|
: ''
|
||||||
return status || '初始化失败'
|
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) {
|
while (this.getRemainingMs(deadline) > 0) {
|
||||||
onStatus?.('正在查找微信进程...', 0)
|
onStatus?.('正在查找微信进程...', 0)
|
||||||
const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000))
|
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))
|
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
|
if (!this.shouldRetryAfterProcessLost(deadline)) return null
|
||||||
onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0)
|
onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0)
|
||||||
await this.delayBeforeRetry()
|
await this.delayBeforeRetry()
|
||||||
return this.waitForNextDbKeyPid(deadline, onStatus)
|
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 (loginRequiredDetected) return true
|
||||||
if (!pid) return false
|
if (!pid) return false
|
||||||
if (!await this.isWeChatPidActive(pid)) return false
|
if (!(await this.isWeChatPidActive(pid))) return false
|
||||||
return await this.detectWeChatLoginRequired(pid)
|
return await this.detectWeChatLoginRequired(pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,56 +422,6 @@ export class KeyService {
|
|||||||
return fallbackPid ?? null
|
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 ---
|
// --- Window Detection ---
|
||||||
|
|
||||||
private getWindowTitle(hWnd: any): string {
|
private getWindowTitle(hWnd: any): string {
|
||||||
@@ -614,7 +451,7 @@ export class KeyService {
|
|||||||
while (Date.now() - startTime < timeoutMs) {
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
let foundPid: number | null = null
|
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
|
if (!this.IsWindowVisible(hWnd)) return true
|
||||||
const title = this.getWindowTitle(hWnd)
|
const title = this.getWindowTitle(hWnd)
|
||||||
if (!this.isWeChatWindowTitle(title)) return true
|
if (!this.isWeChatWindowTitle(title)) return true
|
||||||
@@ -633,14 +470,14 @@ export class KeyService {
|
|||||||
this.koffi.unregister(enumWindowsCallback)
|
this.koffi.unregister(enumWindowsCallback)
|
||||||
|
|
||||||
if (foundPid) return foundPid
|
if (foundPid) return foundPid
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
|
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
|
||||||
const children: 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 title = this.getWindowTitle(hChild).trim()
|
||||||
const className = this.getClassName(hChild).trim()
|
const className = this.getClassName(hChild).trim()
|
||||||
children.push({ title, className })
|
children.push({ title, className })
|
||||||
@@ -655,7 +492,16 @@ export class KeyService {
|
|||||||
if (children.length === 0) return false
|
if (children.length === 0) return false
|
||||||
|
|
||||||
const readyTexts = ['聊天', '登录', '账号']
|
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
|
const readyChildCountThreshold = 14
|
||||||
|
|
||||||
let classMatchCount = 0
|
let classMatchCount = 0
|
||||||
@@ -665,12 +511,12 @@ export class KeyService {
|
|||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const normalizedTitle = child.title.replace(/\s+/g, '')
|
const normalizedTitle = child.title.replace(/\s+/g, '')
|
||||||
if (normalizedTitle) {
|
if (normalizedTitle) {
|
||||||
if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true
|
if (readyTexts.some((marker) => normalizedTitle.includes(marker))) return true
|
||||||
titleMatchCount += 1
|
titleMatchCount += 1
|
||||||
}
|
}
|
||||||
const className = child.className
|
const className = child.className
|
||||||
if (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) {
|
if (className.length > 5) {
|
||||||
classMatchCount += 1
|
classMatchCount += 1
|
||||||
hasValidClassName = true
|
hasValidClassName = true
|
||||||
@@ -685,7 +531,9 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isLoginRelatedText(value: string): boolean {
|
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
|
if (!normalized) return false
|
||||||
const keywords = [
|
const keywords = [
|
||||||
'登录',
|
'登录',
|
||||||
@@ -741,7 +589,7 @@ export class KeyService {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
while (Date.now() - startTime < timeoutMs) {
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
let ready = false
|
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
|
if (!this.IsWindowVisible(hWnd)) return true
|
||||||
const title = this.getWindowTitle(hWnd)
|
const title = this.getWindowTitle(hWnd)
|
||||||
if (!this.isWeChatWindowTitle(title)) return true
|
if (!this.isWeChatWindowTitle(title)) return true
|
||||||
@@ -763,7 +611,7 @@ export class KeyService {
|
|||||||
this.koffi.unregister(enumWindowsCallback)
|
this.koffi.unregister(enumWindowsCallback)
|
||||||
|
|
||||||
if (ready) return true
|
if (ready) return true
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -771,8 +619,8 @@ export class KeyService {
|
|||||||
// --- DB Key Logic (core hook/poll flow unchanged) ---
|
// --- DB Key Logic (core hook/poll flow unchanged) ---
|
||||||
|
|
||||||
async autoGetDbKey(
|
async autoGetDbKey(
|
||||||
timeoutMs = 60_000,
|
timeoutMs = 60_000,
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<DbKeyResult> {
|
): Promise<DbKeyResult> {
|
||||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||||
if (!this.ensureLoaded()) return { success: false, error: this.getLoadError() }
|
if (!this.ensureLoaded()) return { success: false, error: this.getLoadError() }
|
||||||
@@ -794,14 +642,14 @@ export class KeyService {
|
|||||||
onStatus?.('正在检测微信界面组件...', 0)
|
onStatus?.('正在检测微信界面组件...', 0)
|
||||||
await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline)))
|
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)
|
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = this.initHook(pid)
|
const ok = this.initHook(pid)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
if (!await this.isWeChatPidActive(pid)) {
|
if (!(await this.isWeChatPidActive(pid))) {
|
||||||
this.cleanupDbKeyHook()
|
this.cleanupDbKeyHook()
|
||||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||||
continue
|
continue
|
||||||
@@ -828,7 +676,10 @@ export class KeyService {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected)
|
const loginRequired = await this.detectLoginRequiredForLastPid(
|
||||||
|
pid,
|
||||||
|
lastAttemptLoginRequiredDetected
|
||||||
|
)
|
||||||
if (loginRequired) {
|
if (loginRequired) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -840,81 +691,10 @@ export class KeyService {
|
|||||||
return { success: false, error: '获取密钥超时', logs }
|
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(
|
async autoGetImageKey(
|
||||||
manualDir?: string,
|
manualDir?: string,
|
||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
wxidParam?: string
|
wxidParam?: string
|
||||||
): Promise<ImageKeyResult> {
|
): Promise<ImageKeyResult> {
|
||||||
void wxidParam
|
void wxidParam
|
||||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
|
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
|
||||||
@@ -935,9 +715,16 @@ export class KeyService {
|
|||||||
onProgress?.('正在查找模板文件...')
|
onProgress?.('正在查找模板文件...')
|
||||||
let result = await this._findTemplateData(userDir, 32)
|
let result = await this._findTemplateData(userDir, 32)
|
||||||
let { ciphertext, xorKey } = result
|
let { ciphertext, xorKey } = result
|
||||||
const firstDiag = (this as { _imageTemplateDiag?: {
|
const firstDiag = (
|
||||||
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
this as {
|
||||||
} })._imageTemplateDiag
|
_imageTemplateDiag?: {
|
||||||
|
userDir: string
|
||||||
|
totalTFiles: number
|
||||||
|
v2Count: number
|
||||||
|
nonV2Count: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)._imageTemplateDiag
|
||||||
|
|
||||||
// 如果找不到密钥,尝试扫描更多文件
|
// 如果找不到密钥,尝试扫描更多文件
|
||||||
if (ciphertext && xorKey === null) {
|
if (ciphertext && xorKey === null) {
|
||||||
@@ -948,9 +735,17 @@ export class KeyService {
|
|||||||
|
|
||||||
if (!ciphertext) {
|
if (!ciphertext) {
|
||||||
// 用诊断信息给具体提示
|
// 用诊断信息给具体提示
|
||||||
const diag = (this as { _imageTemplateDiag?: {
|
const diag =
|
||||||
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
(
|
||||||
} })._imageTemplateDiag || firstDiag
|
this as {
|
||||||
|
_imageTemplateDiag?: {
|
||||||
|
userDir: string
|
||||||
|
totalTFiles: number
|
||||||
|
v2Count: number
|
||||||
|
nonV2Count: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)._imageTemplateDiag || firstDiag
|
||||||
if (!diag || diag.totalTFiles === 0) {
|
if (!diag || diag.totalTFiles === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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')},正在查找微信进程...`)
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
@@ -1000,7 +799,7 @@ export class KeyService {
|
|||||||
return { success: true, xorKey, aesKey }
|
return { success: true, xorKey, aesKey }
|
||||||
}
|
}
|
||||||
// 等 5 秒再试
|
// 等 5 秒再试
|
||||||
await new Promise(r => setTimeout(r, 5000))
|
await new Promise((r) => setTimeout(r, 5000))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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 { readdirSync, readFileSync, statSync } = await import('fs')
|
||||||
const { join } = await import('path')
|
const { join } = await import('path')
|
||||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
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)
|
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||||
}
|
}
|
||||||
} catch { /* 忽略无权限目录 */ }
|
} catch {
|
||||||
|
/* 忽略无权限目录 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files: string[] = []
|
const files: string[] = []
|
||||||
@@ -1035,7 +839,11 @@ export class KeyService {
|
|||||||
|
|
||||||
// 按修改时间降序
|
// 按修改时间降序
|
||||||
files.sort((a, b) => {
|
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
|
let ciphertext: Buffer | null = null
|
||||||
@@ -1058,17 +866,24 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提取密文(取第一个有效的)
|
// 提取密文(取第一个有效的)
|
||||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1f) {
|
||||||
ciphertext = data.subarray(0xF, 0x1F)
|
ciphertext = data.subarray(0xf, 0x1f)
|
||||||
}
|
}
|
||||||
} catch { /* 忽略 */ }
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算 XOR 密钥
|
// 计算 XOR 密钥
|
||||||
let xorKey: number | null = null
|
let xorKey: number | null = null
|
||||||
let maxCount = 0
|
let maxCount = 0
|
||||||
for (const [key, count] of Object.entries(tailCounts)) {
|
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 直接告诉用户搜到了什么
|
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
|
||||||
@@ -1091,8 +906,19 @@ export class KeyService {
|
|||||||
if (!this.ensureKernel32()) return null
|
if (!this.ensureKernel32()) return null
|
||||||
|
|
||||||
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
|
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
|
||||||
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t'])
|
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', [
|
||||||
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('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 保护标志(只扫可写区域,速度更快)
|
// RW 保护标志(只扫可写区域,速度更快)
|
||||||
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
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 PAGE_GUARD = 0x100
|
||||||
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
|
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
|
if (!hProcess) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1110,7 +936,7 @@ export class KeyService {
|
|||||||
let addr = 0
|
let addr = 0
|
||||||
const mbi = Buffer.alloc(MBI_SIZE)
|
const mbi = Buffer.alloc(MBI_SIZE)
|
||||||
|
|
||||||
while (addr < 0x7FFFFFFFFFFF) {
|
while (addr < 0x7fffffffffff) {
|
||||||
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
|
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
|
||||||
if (ret === 0) break
|
if (ret === 0) break
|
||||||
// MEMORY_BASIC_INFORMATION x64 布局:
|
// MEMORY_BASIC_INFORMATION x64 布局:
|
||||||
@@ -1126,11 +952,13 @@ export class KeyService {
|
|||||||
const state = mbi.readUInt32LE(32)
|
const state = mbi.readUInt32LE(32)
|
||||||
const protect = mbi.readUInt32LE(36)
|
const protect = mbi.readUInt32LE(36)
|
||||||
|
|
||||||
if (state === MEM_COMMIT &&
|
if (
|
||||||
protect !== PAGE_NOACCESS &&
|
state === MEM_COMMIT &&
|
||||||
(protect & PAGE_GUARD) === 0 &&
|
protect !== PAGE_NOACCESS &&
|
||||||
(protect & RW_FLAGS) !== 0 &&
|
(protect & PAGE_GUARD) === 0 &&
|
||||||
size <= 50 * 1024 * 1024) {
|
(protect & RW_FLAGS) !== 0 &&
|
||||||
|
size <= 50 * 1024 * 1024
|
||||||
|
) {
|
||||||
regions.push([base, size])
|
regions.push([base, size])
|
||||||
}
|
}
|
||||||
const next = base + size
|
const next = base + size
|
||||||
@@ -1148,7 +976,7 @@ export class KeyService {
|
|||||||
const [base, size] = regions[i]
|
const [base, size] = regions[i]
|
||||||
if (i % 20 === 0) {
|
if (i % 20 === 0) {
|
||||||
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
|
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
|
||||||
await new Promise(r => setTimeout(r, 1)) // 让出事件循环
|
await new Promise((r) => setTimeout(r, 1)) // 让出事件循环
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = 0
|
let offset = 0
|
||||||
@@ -1159,17 +987,29 @@ export class KeyService {
|
|||||||
const buf = Buffer.alloc(chunkSize)
|
const buf = Buffer.alloc(chunkSize)
|
||||||
const bytesReadOut = [0]
|
const bytesReadOut = [0]
|
||||||
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
|
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字节密钥
|
// 搜索 ASCII 32字节密钥
|
||||||
const key = this._searchAsciiKey(data, ciphertext)
|
const key = this._searchAsciiKey(data, ciphertext)
|
||||||
if (key) { this.CloseHandle(hProcess); return key }
|
if (key) {
|
||||||
|
this.CloseHandle(hProcess)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
// 搜索 UTF-16LE 32字节密钥
|
// 搜索 UTF-16LE 32字节密钥
|
||||||
const key16 = this._searchUtf16Key(data, ciphertext)
|
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))
|
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
|
||||||
offset += chunkSize
|
offset += chunkSize
|
||||||
@@ -1187,12 +1027,16 @@ export class KeyService {
|
|||||||
if (this._isAlphaNum(data[i])) continue
|
if (this._isAlphaNum(data[i])) continue
|
||||||
let valid = true
|
let valid = true
|
||||||
for (let j = 1; j <= 32; j++) {
|
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 (!valid) continue
|
||||||
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
|
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
|
||||||
const keyBytes = data.subarray(i + 1, i + 33)
|
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
|
return null
|
||||||
}
|
}
|
||||||
@@ -1201,18 +1045,22 @@ export class KeyService {
|
|||||||
for (let i = 0; i < data.length - 65; i++) {
|
for (let i = 0; i < data.length - 65; i++) {
|
||||||
let valid = true
|
let valid = true
|
||||||
for (let j = 0; j < 32; j++) {
|
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
|
if (!valid) continue
|
||||||
const keyBytes = Buffer.alloc(32)
|
const keyBytes = Buffer.alloc(32)
|
||||||
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private _isAlphaNum(b: number): boolean {
|
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 {
|
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
|
||||||
@@ -1221,12 +1069,14 @@ export class KeyService {
|
|||||||
decipher.setAutoPadding(false)
|
decipher.setAutoPadding(false)
|
||||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
// 支持 JPEG / PNG / WEBP / WXGF / GIF
|
// 支持 JPEG / PNG / WEBP / WXGF / GIF
|
||||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) 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] === 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] === 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] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
return false
|
return false
|
||||||
} catch { return false }
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ type VideoContent = {
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -122,6 +123,9 @@ export type ParsedContent =
|
|||||||
| UnknownContent
|
| UnknownContent
|
||||||
|
|
||||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
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') {
|
if (!content || typeof content !== 'string') {
|
||||||
return { type: 'unknown', raw: content || '' }
|
return { type: 'unknown', raw: content || '' }
|
||||||
}
|
}
|
||||||
@@ -131,8 +135,6 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
switch (messageType) {
|
switch (messageType) {
|
||||||
case 1:
|
case 1:
|
||||||
return { type: 'text', content: normalized }
|
return { type: 'text', content: normalized }
|
||||||
case 34:
|
|
||||||
return { type: 'voice' }
|
|
||||||
case 3:
|
case 3:
|
||||||
return parseImageMessage(normalized)
|
return parseImageMessage(normalized)
|
||||||
case 42:
|
case 42:
|
||||||
@@ -160,12 +162,11 @@ function parseVideoMessage(content: string): ParsedContent {
|
|||||||
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||||
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||||
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
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 duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||||
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||||
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || 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 {
|
function parseSystemMessage(content: string): ParsedContent {
|
||||||
@@ -490,8 +491,7 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const articles = parseShareArticles(content)
|
const articles = parseShareArticles(content)
|
||||||
const title =
|
const title = articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||||
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
|
||||||
const des =
|
const des =
|
||||||
articles[0]?.description ||
|
articles[0]?.description ||
|
||||||
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
|
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
|
||||||
|
|||||||
@@ -33,5 +33,5 @@ try {
|
|||||||
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||||
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
|
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WechatExplorer] failed to enforce local DLL priority:', error)
|
console.error('[TraceMemo] failed to enforce local DLL priority:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function getResourceRoots(): string[] {
|
|||||||
const execDir = dirname(process.execPath)
|
const execDir = dirname(process.execPath)
|
||||||
|
|
||||||
return unique([
|
return unique([
|
||||||
|
process.env.TRACEMEMO_RESOURCES_PATH || '',
|
||||||
process.env.WECHATEXPLORER_RESOURCES_PATH || '',
|
process.env.WECHATEXPLORER_RESOURCES_PATH || '',
|
||||||
join(process.cwd(), 'resources'),
|
join(process.cwd(), 'resources'),
|
||||||
join(process.cwd(), 'resources', 'resources'),
|
join(process.cwd(), 'resources', 'resources'),
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ class AgentHubService {
|
|||||||
|
|
||||||
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
|
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
|
||||||
if (!isReady()) {
|
if (!isReady()) {
|
||||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const items = listRecentChat(limit)
|
const items = listRecentChat(limit)
|
||||||
@@ -474,7 +474,7 @@ class AgentHubService {
|
|||||||
const result = await agentAIProvider.chat([
|
const result = await agentAIProvider.chat([
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
|
content: `你是 TraceMemo 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
|
||||||
支持的工具:
|
支持的工具:
|
||||||
1. recent:查看最近会话,参数 limit 为 1-20。
|
1. recent:查看最近会话,参数 limit 为 1-20。
|
||||||
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
|
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
|
||||||
@@ -544,7 +544,7 @@ class AgentHubService {
|
|||||||
intent: ContactChatIntent
|
intent: ContactChatIntent
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isReady()) {
|
if (!isReady()) {
|
||||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +580,7 @@ class AgentHubService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!isReady()) {
|
if (!isReady()) {
|
||||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const contact = resolveMd5(intent.contact)
|
const contact = resolveMd5(intent.contact)
|
||||||
@@ -642,7 +642,7 @@ class AgentHubService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!isReady()) {
|
if (!isReady()) {
|
||||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const group = this.resolveGroup(intent.group)
|
const group = this.resolveGroup(intent.group)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const agentSystemPrompt = (
|
|||||||
question: string,
|
question: string,
|
||||||
scopeLabel: string,
|
scopeLabel: string,
|
||||||
rangeLabel: string
|
rangeLabel: string
|
||||||
): string => `你是 WechatExplorer 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
|
): string => `你是 TraceMemo 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
|
||||||
用户问题:${question}
|
用户问题:${question}
|
||||||
允许范围:${scopeLabel};时间范围:${rangeLabel}。
|
允许范围:${scopeLabel};时间范围:${rangeLabel}。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, session } from 'electron'
|
import { app, session, shell } from 'electron'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { clearBootstrapCache } from './bootstrap-cache'
|
import { clearBootstrapCache } from './bootstrap-cache'
|
||||||
@@ -62,7 +62,8 @@ export function getCacheSummary(): CacheSummary {
|
|||||||
{
|
{
|
||||||
id: 'knowledge',
|
id: 'knowledge',
|
||||||
label: '本地知识库索引',
|
label: '本地知识库索引',
|
||||||
description: '为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
|
description:
|
||||||
|
'为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
|
||||||
...knowledge
|
...knowledge
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -90,3 +91,13 @@ export async function clearCache(
|
|||||||
}
|
}
|
||||||
return getCacheSummary()
|
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,7 +9,12 @@ import type {
|
|||||||
DatabaseKeyValidationCode,
|
DatabaseKeyValidationCode,
|
||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult
|
||||||
} from '../../shared/database-key'
|
} from '../../shared/database-key'
|
||||||
|
import {
|
||||||
|
isWindowsVcRuntimeMissingError,
|
||||||
|
WINDOWS_VC_RUNTIME_ERROR_MESSAGE
|
||||||
|
} from '../../shared/windows-runtime'
|
||||||
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||||
|
import type { ExportImageQuality } from '../../shared/image-quality'
|
||||||
|
|
||||||
export function getCurrentKey(): string {
|
export function getCurrentKey(): string {
|
||||||
if (!dbRef) return ''
|
if (!dbRef) return ''
|
||||||
@@ -60,6 +65,7 @@ export interface FormattedMessage {
|
|||||||
exportMediaUrl?: string
|
exportMediaUrl?: string
|
||||||
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
||||||
exportMediaName?: string
|
exportMediaName?: string
|
||||||
|
exportMediaQuality?: ExportImageQuality
|
||||||
exportShowAvatar?: boolean
|
exportShowAvatar?: boolean
|
||||||
exportMediaError?: string
|
exportMediaError?: string
|
||||||
exportAvatarUrl?: string
|
exportAvatarUrl?: string
|
||||||
@@ -295,7 +301,7 @@ function listSourceMessages(
|
|||||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||||
? 49
|
? 49
|
||||||
: msgType
|
: 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 {
|
try {
|
||||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||||
const hasStickerPayload =
|
const hasStickerPayload =
|
||||||
@@ -673,11 +679,13 @@ const DATABASE_KEY_ERROR_MESSAGES: Record<DatabaseKeyValidationCode, string> = {
|
|||||||
ACCOUNT_MISMATCH: '密钥与当前账号不匹配',
|
ACCOUNT_MISMATCH: '密钥与当前账号不匹配',
|
||||||
ROOT_UNAVAILABLE: '当前数据库目录不可用',
|
ROOT_UNAVAILABLE: '当前数据库目录不可用',
|
||||||
DATABASE_FILE_MISSING: '数据库文件缺失',
|
DATABASE_FILE_MISSING: '数据库文件缺失',
|
||||||
|
VC_RUNTIME_MISSING: WINDOWS_VC_RUNTIME_ERROR_MESSAGE,
|
||||||
UNKNOWN_VALIDATION_ERROR: '未知验证错误'
|
UNKNOWN_VALIDATION_ERROR: '未知验证错误'
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapConnectionError(detail: string): DatabaseKeyValidationCode {
|
function mapConnectionError(detail: string): DatabaseKeyValidationCode {
|
||||||
const normalized = detail.toLowerCase()
|
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('-1005') || normalized.includes('不匹配')) return 'ACCOUNT_MISMATCH'
|
||||||
if (normalized.includes('session.db') || normalized.includes('数据库文件')) {
|
if (normalized.includes('session.db') || normalized.includes('数据库文件')) {
|
||||||
return 'DATABASE_FILE_MISSING'
|
return 'DATABASE_FILE_MISSING'
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function inspectImageDecryptionStatus(
|
|||||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||||
const stickerCacheFound =
|
const stickerCacheFound =
|
||||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||||
|
fs.existsSync(path.join(os.homedir(), 'Documents', 'TraceMemo', 'Emojis')) ||
|
||||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
||||||
const dbConnected = chat.isReady()
|
const dbConnected = chat.isReady()
|
||||||
const [wechatRunning, decoder] = await Promise.all([
|
const [wechatRunning, decoder] = await Promise.all([
|
||||||
@@ -287,7 +288,7 @@ export function buildImageTestDiagnosticLog(input: {
|
|||||||
const rootIsDirectory = rootExists ? safeIsDirectory(root) : false
|
const rootIsDirectory = rootExists ? safeIsDirectory(root) : false
|
||||||
const resultCode = input.result.success ? 'SUCCESS' : input.result.code || 'UNKNOWN'
|
const resultCode = input.result.success ? 'SUCCESS' : input.result.code || 'UNKNOWN'
|
||||||
return [
|
return [
|
||||||
'WechatExplorer 图片解析测试日志(已脱敏)',
|
'TraceMemo 图片解析测试日志(已脱敏)',
|
||||||
`时间:${new Date().toISOString()}`,
|
`时间:${new Date().toISOString()}`,
|
||||||
`应用版本:${safeAppVersion()}`,
|
`应用版本:${safeAppVersion()}`,
|
||||||
`运行环境:${process.platform} ${process.arch}`,
|
`运行环境:${process.platform} ${process.arch}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/main/services/image-insight-service.ts
|
// src/main/services/image-insight-service.ts
|
||||||
// WechatExplorer AI 图片理解基础设施
|
// TraceMemo AI 图片理解基础设施
|
||||||
//
|
//
|
||||||
// 设计原则:
|
// 设计原则:
|
||||||
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { existsSync, promises as fs } from 'fs'
|
|||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { isPackagedRuntime } from '../runtime-mode'
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
|
||||||
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
const SKILL_RELATIVE_PATHS = [
|
||||||
const GITHUB_URL =
|
join('skill', 'tracememo-reader', 'SKILL.md'),
|
||||||
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
|
join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||||
const SKILL_VERSION = 'v1.1'
|
]
|
||||||
|
const GITHUB_URL = 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
|
||||||
|
const SKILL_VERSION = 'v1.2'
|
||||||
|
|
||||||
type SkillResourceSource = 'development' | 'bundled'
|
type SkillResourceSource = 'development' | 'bundled'
|
||||||
|
|
||||||
@@ -54,19 +56,19 @@ function uniqueCandidates(candidates: SkillCandidate[]): SkillCandidate[] {
|
|||||||
|
|
||||||
export function getSkillCandidates(environment?: SkillPathEnvironment): SkillCandidate[] {
|
export function getSkillCandidates(environment?: SkillPathEnvironment): SkillCandidate[] {
|
||||||
const runtime = environment || currentEnvironment()
|
const runtime = environment || currentEnvironment()
|
||||||
const developmentPaths = [
|
const developmentPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
|
||||||
join(runtime.appPath, 'docs', SKILL_RELATIVE_PATH),
|
join(runtime.appPath, 'docs', relativePath),
|
||||||
join(runtime.cwd, 'docs', SKILL_RELATIVE_PATH),
|
join(runtime.cwd, 'docs', relativePath),
|
||||||
join(dirname(runtime.appPath), 'docs', SKILL_RELATIVE_PATH)
|
join(dirname(runtime.appPath), 'docs', relativePath)
|
||||||
]
|
])
|
||||||
const execDirectory = dirname(runtime.execPath)
|
const execDirectory = dirname(runtime.execPath)
|
||||||
const bundledPaths = [
|
const bundledPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
|
||||||
join(runtime.resourcesPath, SKILL_RELATIVE_PATH),
|
join(runtime.resourcesPath, relativePath),
|
||||||
join(runtime.resourcesPath, 'resources', SKILL_RELATIVE_PATH),
|
join(runtime.resourcesPath, 'resources', relativePath),
|
||||||
join(dirname(runtime.appPath), SKILL_RELATIVE_PATH),
|
join(dirname(runtime.appPath), relativePath),
|
||||||
join(execDirectory, 'resources', SKILL_RELATIVE_PATH),
|
join(execDirectory, 'resources', relativePath),
|
||||||
join(dirname(execDirectory), 'Resources', SKILL_RELATIVE_PATH)
|
join(dirname(execDirectory), 'Resources', relativePath)
|
||||||
]
|
])
|
||||||
return uniqueCandidates(
|
return uniqueCandidates(
|
||||||
runtime.packaged
|
runtime.packaged
|
||||||
? bundledPaths.map((path) => ({ path, source: 'bundled' }))
|
? bundledPaths.map((path) => ({ path, source: 'bundled' }))
|
||||||
@@ -90,7 +92,7 @@ export function resolveSkillResourceStatus(
|
|||||||
available: false,
|
available: false,
|
||||||
source,
|
source,
|
||||||
githubUrl: GITHUB_URL,
|
githubUrl: GITHUB_URL,
|
||||||
error: `未找到 WechatExplorer Reader Skill 文件(已检查:${candidates.map((item) => item.path).join(';')})`
|
error: `未找到 TraceMemo Reader Skill 文件(已检查:${candidates.map((item) => item.path).join(';')})`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ const downloadCache = new Map<string, Promise<StickerResult>>()
|
|||||||
|
|
||||||
export class StickerService {
|
export class StickerService {
|
||||||
private readonly cacheDir: string
|
private readonly cacheDir: string
|
||||||
|
private readonly legacyCacheDir: string
|
||||||
|
|
||||||
constructor(private readonly wcdb4Client?: Wcdb4Client | null) {
|
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> {
|
async resolveSticker(cdnUrl?: string, md5?: string): Promise<StickerResult> {
|
||||||
@@ -69,7 +72,7 @@ export class StickerService {
|
|||||||
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||||
const cacheDirs = [
|
const cacheDirs = [
|
||||||
this.cacheDir,
|
this.cacheDir,
|
||||||
path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
this.legacyCacheDir
|
||||||
]
|
]
|
||||||
for (const cacheDir of cacheDirs) {
|
for (const cacheDir of cacheDirs) {
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
@@ -128,7 +131,7 @@ export class StickerService {
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 MicroMessenger WechatExplorer',
|
'User-Agent': 'Mozilla/5.0 MicroMessenger TraceMemo',
|
||||||
Referer: 'https://weixin.qq.com/'
|
Referer: 'https://weixin.qq.com/'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type VideoAsset = {
|
|||||||
|
|
||||||
export type VideoResolveOptions = {
|
export type VideoResolveOptions = {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -52,7 +53,13 @@ export class VideoAssetService {
|
|||||||
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
.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(
|
const hardlinkDb = path.join(
|
||||||
this.client.getAccountRoot(),
|
this.client.getAccountRoot(),
|
||||||
@@ -178,6 +185,21 @@ export class VideoAssetService {
|
|||||||
|
|
||||||
let narrowed = assets
|
let narrowed = assets
|
||||||
let appliedCriteria = 0
|
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 width = Number(options.width)
|
||||||
const height = Number(options.height)
|
const height = Number(options.height)
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
|
|||||||
+207
-146
@@ -58,6 +58,17 @@ export interface Wcdb4GroupMember {
|
|||||||
m_nsHeadImgUrl: string
|
m_nsHeadImgUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系人表中独立保存的成员名称字段。
|
||||||
|
*
|
||||||
|
* 群成员接口的 nickname 在部分微信数据版本中会被通讯录备注覆盖,因此不能用它
|
||||||
|
* 推断微信昵称或备注。
|
||||||
|
*/
|
||||||
|
type Wcdb4ContactMemberNames = {
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Wcdb4ImageHardlink {
|
export interface Wcdb4ImageHardlink {
|
||||||
file_name?: string
|
file_name?: string
|
||||||
full_path?: string
|
full_path?: string
|
||||||
@@ -261,20 +272,38 @@ export function resolveWindowsNativeAccountRoot(
|
|||||||
.find((candidate) => candidate && isAsciiPath(candidate))
|
.find((candidate) => candidate && isAsciiPath(candidate))
|
||||||
if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot
|
if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot
|
||||||
|
|
||||||
const bridgeRoot = path.join(publicRoot, 'WechatExplorer', 'path-bridges')
|
const bridgeId = crypto
|
||||||
const bridgePath = path.join(
|
.createHash('sha256')
|
||||||
bridgeRoot,
|
.update(path.resolve(accountRoot).toLowerCase())
|
||||||
crypto
|
.digest('hex')
|
||||||
.createHash('sha256')
|
.slice(0, 24)
|
||||||
.update(path.resolve(accountRoot).toLowerCase())
|
const legacyBridgePath = path.join(publicRoot, 'WechatExplorer', 'path-bridges', bridgeId)
|
||||||
.digest('hex')
|
try {
|
||||||
.slice(0, 24)
|
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 {
|
try {
|
||||||
fs.ensureDirSync(bridgeRoot)
|
fs.ensureDirSync(bridgeRoot)
|
||||||
if (fs.existsSync(bridgePath)) {
|
if (fs.existsSync(bridgePath)) {
|
||||||
const existingTarget = fs.realpathSync.native(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
|
return bridgePath
|
||||||
}
|
}
|
||||||
fs.removeSync(bridgePath)
|
fs.removeSync(bridgePath)
|
||||||
@@ -1684,70 +1713,16 @@ export class Wcdb4Client {
|
|||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
|
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
|
||||||
)
|
)
|
||||||
|
const memberRows = Array.isArray(rows) ? rows : []
|
||||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
const contactNames = this.readContactMemberNames(this.groupMemberUsernames(memberRows))
|
||||||
const username = this.pickString(row, [
|
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
|
||||||
'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)
|
|
||||||
this.hydrateAvatarUrls(
|
this.hydrateAvatarUrls(
|
||||||
members
|
members
|
||||||
.filter((member) => !member.m_nsHeadImgUrl)
|
.filter((member) => !member.m_nsHeadImgUrl)
|
||||||
.map((member) => member.m_nsUsrName)
|
.map((member) => member.m_nsUsrName)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
return members.map((member) => ({
|
return this.withCachedGroupMemberAvatars(members)
|
||||||
...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 {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -1821,70 +1796,98 @@ export class Wcdb4Client {
|
|||||||
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
||||||
chatroomId
|
chatroomId
|
||||||
)
|
)
|
||||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
const memberRows = Array.isArray(rows) ? rows : []
|
||||||
const username = this.pickString(row, [
|
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',
|
||||||
'userName',
|
'userName',
|
||||||
'user_name',
|
'user_name',
|
||||||
'member_username',
|
'member_username',
|
||||||
'm_nsUsrName'
|
'm_nsUsrName'
|
||||||
])
|
])
|
||||||
const wechatNickname = this.pickString(row, [
|
)
|
||||||
'nickname',
|
.filter(Boolean)
|
||||||
'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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const missingNames = members
|
/**
|
||||||
.filter((member) => !member.nickname)
|
* 为已标准化的成员补充头像缓存,不参与名称字段的回退。
|
||||||
.map((member) => member.m_nsUsrName)
|
*/
|
||||||
.filter(Boolean)
|
private withCachedGroupMemberAvatars(members: Wcdb4GroupMember[]): Wcdb4GroupMember[] {
|
||||||
const missingAvatars = members
|
return members.map((member) => ({
|
||||||
.filter((member) => !member.m_nsHeadImgUrl)
|
...member,
|
||||||
.map((member) => member.m_nsUsrName)
|
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(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 []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||||
@@ -2655,27 +2658,6 @@ export class Wcdb4Client {
|
|||||||
return false
|
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> {
|
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
|
||||||
if (!this.wcdbGetDisplayNames) return
|
if (!this.wcdbGetDisplayNames) return
|
||||||
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
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> {
|
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
||||||
const result = new Map<string, string>()
|
const result = new Map<string, string>()
|
||||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||||
|
|||||||
Vendored
+16
-6
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../shared/report-history'
|
} from '../shared/report-history'
|
||||||
import type {
|
import type {
|
||||||
DatabaseKeyEnvironment,
|
DatabaseKeyEnvironment,
|
||||||
|
DatabaseInitResult,
|
||||||
DatabaseKeyStorageResult,
|
DatabaseKeyStorageResult,
|
||||||
DatabaseKeyValidationResult,
|
DatabaseKeyValidationResult,
|
||||||
AccountDiscoveryResult
|
AccountDiscoveryResult
|
||||||
@@ -57,7 +58,8 @@ import type {
|
|||||||
VoiceModelDownloadResult,
|
VoiceModelDownloadResult,
|
||||||
VoiceModelProgressEvent,
|
VoiceModelProgressEvent,
|
||||||
VoiceModelStatus,
|
VoiceModelStatus,
|
||||||
VoiceRecognitionResult
|
VoiceRecognitionResult,
|
||||||
|
VoiceTranscriptSnapshot
|
||||||
} from '../shared/voice-recognition'
|
} from '../shared/voice-recognition'
|
||||||
import type {
|
import type {
|
||||||
AiSearchCancelResult,
|
AiSearchCancelResult,
|
||||||
@@ -102,6 +104,7 @@ export type ParsedContent =
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -140,10 +143,8 @@ declare global {
|
|||||||
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
||||||
getCacheSummary: () => Promise<CacheSummary>
|
getCacheSummary: () => Promise<CacheSummary>
|
||||||
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
|
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
|
||||||
initDb: (
|
openKnowledgeDirectory: () => Promise<{ success: boolean; error?: string }>
|
||||||
key: string,
|
initDb: (key: string, accountRoot: string) => Promise<boolean | DatabaseInitResult>
|
||||||
accountRoot: string
|
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
|
||||||
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
|
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
|
||||||
getBootstrapCache: () => Promise<{
|
getBootstrapCache: () => Promise<{
|
||||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
@@ -247,6 +248,9 @@ declare global {
|
|||||||
removeVoiceModel: () => Promise<VoiceModelStatus>
|
removeVoiceModel: () => Promise<VoiceModelStatus>
|
||||||
openVoiceModelDirectory: () => Promise<{ success: boolean; error?: string }>
|
openVoiceModelDirectory: () => Promise<{ success: boolean; error?: string }>
|
||||||
recognizeVoice: (reference: VoiceMessageReference) => Promise<VoiceRecognitionResult>
|
recognizeVoice: (reference: VoiceMessageReference) => Promise<VoiceRecognitionResult>
|
||||||
|
getVoiceTranscriptSnapshot: (
|
||||||
|
reference: VoiceMessageReference
|
||||||
|
) => Promise<VoiceTranscriptSnapshot>
|
||||||
cancelVoiceRecognition: (reference: VoiceMessageReference) => Promise<{ success: boolean }>
|
cancelVoiceRecognition: (reference: VoiceMessageReference) => Promise<{ success: boolean }>
|
||||||
getVoiceBatchPreflight: (request: VoiceBatchRequest) => Promise<VoiceBatchPreflight>
|
getVoiceBatchPreflight: (request: VoiceBatchRequest) => Promise<VoiceBatchPreflight>
|
||||||
getVoiceBatchConversationSummaries: (
|
getVoiceBatchConversationSummaries: (
|
||||||
@@ -278,7 +282,13 @@ declare global {
|
|||||||
}>
|
}>
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[],
|
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 }>
|
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
|
|||||||
+15
-2
@@ -34,7 +34,8 @@ import type {
|
|||||||
VoiceModelDownloadResult,
|
VoiceModelDownloadResult,
|
||||||
VoiceModelProgressEvent,
|
VoiceModelProgressEvent,
|
||||||
VoiceModelStatus,
|
VoiceModelStatus,
|
||||||
VoiceRecognitionResult
|
VoiceRecognitionResult,
|
||||||
|
VoiceTranscriptSnapshot
|
||||||
} from '../shared/voice-recognition'
|
} from '../shared/voice-recognition'
|
||||||
import type {
|
import type {
|
||||||
AiSearchCancelResult,
|
AiSearchCancelResult,
|
||||||
@@ -66,6 +67,8 @@ const api = {
|
|||||||
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
||||||
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<CacheSummary> =>
|
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<CacheSummary> =>
|
||||||
ipcRenderer.invoke('cache:clear', scope),
|
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),
|
initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
|
||||||
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
||||||
ipcRenderer.invoke('accounts:discover', inputPath),
|
ipcRenderer.invoke('accounts:discover', inputPath),
|
||||||
@@ -137,6 +140,10 @@ const api = {
|
|||||||
ipcRenderer.invoke('voice:openModelDirectory'),
|
ipcRenderer.invoke('voice:openModelDirectory'),
|
||||||
recognizeVoice: (reference: VoiceMessageReference): Promise<VoiceRecognitionResult> =>
|
recognizeVoice: (reference: VoiceMessageReference): Promise<VoiceRecognitionResult> =>
|
||||||
ipcRenderer.invoke('voice:recognize', reference),
|
ipcRenderer.invoke('voice:recognize', reference),
|
||||||
|
getVoiceTranscriptSnapshot: (
|
||||||
|
reference: VoiceMessageReference
|
||||||
|
): Promise<VoiceTranscriptSnapshot> =>
|
||||||
|
ipcRenderer.invoke('voice:getTranscriptSnapshot', reference),
|
||||||
cancelVoiceRecognition: (reference: VoiceMessageReference): Promise<{ success: boolean }> =>
|
cancelVoiceRecognition: (reference: VoiceMessageReference): Promise<{ success: boolean }> =>
|
||||||
ipcRenderer.invoke('voice:cancelRecognition', reference),
|
ipcRenderer.invoke('voice:cancelRecognition', reference),
|
||||||
getVoiceBatchPreflight: (request: VoiceBatchRequest): Promise<VoiceBatchPreflight> =>
|
getVoiceBatchPreflight: (request: VoiceBatchRequest): Promise<VoiceBatchPreflight> =>
|
||||||
@@ -179,7 +186,13 @@ const api = {
|
|||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[],
|
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),
|
) => ipcRenderer.invoke('db:getVideo', hashes, options),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>WechatExplorer</title>
|
<title>TraceMemo(迹忆)</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/assets/brand-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/src/assets/brand-icon.svg" />
|
||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta
|
<meta
|
||||||
|
|||||||
@@ -378,12 +378,19 @@ function App(): React.ReactElement {
|
|||||||
const handleStartExport = async (
|
const handleStartExport = async (
|
||||||
request: ExportRequest
|
request: ExportRequest
|
||||||
): Promise<import('../../shared/export').ExportResult> => {
|
): Promise<import('../../shared/export').ExportResult> => {
|
||||||
const targetNames = request.targets.map((target) => target.name)
|
const exportAll = request.scope === 'all'
|
||||||
const targetLabel =
|
const targetCount = request.targets.length
|
||||||
targetNames.length > 1 ? `${targetNames[0]} 等 ${targetNames.length} 个聊天` : targetNames[0]
|
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 = {
|
const task: ExportTaskRecord = {
|
||||||
jobId: request.jobId,
|
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,
|
targetNames,
|
||||||
targetLabel,
|
targetLabel,
|
||||||
format: request.format,
|
format: request.format,
|
||||||
@@ -798,7 +805,7 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
setStartupProgress({
|
setStartupProgress({
|
||||||
title: '正在加载账号信息...',
|
title: '正在加载账号信息...',
|
||||||
subtitle: '即将进入 WechatExplorer',
|
subtitle: '即将进入 TraceMemo',
|
||||||
detail: '正在读取联系人和当前账号',
|
detail: '正在读取联系人和当前账号',
|
||||||
percent: 70
|
percent: 70
|
||||||
})
|
})
|
||||||
@@ -1677,6 +1684,8 @@ function App(): React.ReactElement {
|
|||||||
<ReportTaskStatusPanel
|
<ReportTaskStatusPanel
|
||||||
phase={reportGeneration.phase}
|
phase={reportGeneration.phase}
|
||||||
error={reportGeneration.error}
|
error={reportGeneration.error}
|
||||||
|
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
|
||||||
|
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
reportGeneration.resetGenerationStatus()
|
reportGeneration.resetGenerationStatus()
|
||||||
void reportGeneration.retry()
|
void reportGeneration.retry()
|
||||||
@@ -1804,7 +1813,7 @@ function App(): React.ReactElement {
|
|||||||
? autoConnectSource === 'env'
|
? autoConnectSource === 'env'
|
||||||
? '检测到环境变量中的密钥'
|
? '检测到环境变量中的密钥'
|
||||||
: '使用上次安全保存的密钥'
|
: '使用上次安全保存的密钥'
|
||||||
: 'WechatExplorer')
|
: 'TraceMemo')
|
||||||
return (
|
return (
|
||||||
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
|
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
|
||||||
<div className="boot-splash-spinner" aria-hidden />
|
<div className="boot-splash-spinner" aria-hidden />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
|
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
|
||||||
|
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
|
||||||
|
|
||||||
const GUIDE_URL =
|
const GUIDE_URL =
|
||||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||||
@@ -140,12 +141,12 @@ export function DatabaseConnectionPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="database-login-page">
|
<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-brand-content">
|
||||||
<div className="database-login-logo" aria-hidden="true">
|
<div className="database-login-logo" aria-hidden="true">
|
||||||
<LineIcon name="database" />
|
<LineIcon name="database" />
|
||||||
</div>
|
</div>
|
||||||
<h1>WechatExplorer</h1>
|
<h1>TraceMemo(迹忆)</h1>
|
||||||
<p className="database-login-tagline">让 AI 读懂你的微信</p>
|
<p className="database-login-tagline">让 AI 读懂你的微信</p>
|
||||||
<p className="database-login-description">
|
<p className="database-login-description">
|
||||||
连接成功后,你可以搜索聊天记录、生成群聊日报,并按需使用 AI 分析。
|
连接成功后,你可以搜索聊天记录、生成群聊日报,并按需使用 AI 分析。
|
||||||
@@ -260,7 +261,7 @@ export function DatabaseConnectionPage({
|
|||||||
'确认下方检测结果;没有找到目录时可以手动选择。',
|
'确认下方检测结果;没有找到目录时可以手动选择。',
|
||||||
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
|
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
|
||||||
'开始后请按页面提示完成系统授权。',
|
'开始后请按页面提示完成系统授权。',
|
||||||
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
|
'正在准备连接组件,请不要关闭微信或 TraceMemo。',
|
||||||
'请回到微信完成登录,登录成功后再回来验证。',
|
'请回到微信完成登录,登录成功后再回来验证。',
|
||||||
'正在验证密钥和本地数据库,请稍候。'
|
'正在验证密钥和本地数据库,请稍候。'
|
||||||
][guideStep - 1]}
|
][guideStep - 1]}
|
||||||
@@ -461,7 +462,12 @@ export function DatabaseConnectionPage({
|
|||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Windows 已完整支持,不需要关闭 SIP。'
|
<>
|
||||||
|
Windows 需要 Microsoft Visual C++ 2015-2022 x64 运行库。{' '}
|
||||||
|
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||||
|
下载运行库
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{showMacKeyFaq && isMac && (
|
{showMacKeyFaq && isMac && (
|
||||||
@@ -521,6 +527,14 @@ export function DatabaseConnectionPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="database-login-primary"
|
className="database-login-primary"
|
||||||
@@ -553,7 +567,7 @@ export function DatabaseConnectionPage({
|
|||||||
<button type="button" onClick={onClearKey}>
|
<button type="button" onClick={onClearKey}>
|
||||||
清除已保存密钥
|
清除已保存密钥
|
||||||
</button>
|
</button>
|
||||||
<span>WechatExplorer</span>
|
<span>TraceMemo</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
|||||||
API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center
|
API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center
|
||||||
中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。
|
中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。
|
||||||
<br />
|
<br />
|
||||||
配置文档:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
|
配置文档:<code>docs/skill/tracememo-reader/SKILL.md</code>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface VideoBubbleProps {
|
|||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -15,6 +16,7 @@ export function VideoBubble({
|
|||||||
newMd5,
|
newMd5,
|
||||||
rawMd5,
|
rawMd5,
|
||||||
createTime,
|
createTime,
|
||||||
|
byteLength,
|
||||||
duration,
|
duration,
|
||||||
width,
|
width,
|
||||||
height
|
height
|
||||||
@@ -28,7 +30,7 @@ export function VideoBubble({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
window.api
|
window.api
|
||||||
.getVideo(hashes, { createTime, duration, width, height })
|
.getVideo(hashes, { createTime, byteLength, duration, width, height })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||||
})
|
})
|
||||||
@@ -38,7 +40,7 @@ export function VideoBubble({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [createTime, duration, hashes, height, width])
|
}, [byteLength, createTime, duration, hashes, height, width])
|
||||||
|
|
||||||
if (!media.url) {
|
if (!media.url) {
|
||||||
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function MessageBubble({
|
|||||||
newMd5={message.contentData.newMd5}
|
newMd5={message.contentData.newMd5}
|
||||||
rawMd5={message.contentData.rawMd5}
|
rawMd5={message.contentData.rawMd5}
|
||||||
createTime={message.createTime}
|
createTime={message.createTime}
|
||||||
|
byteLength={message.contentData.byteLength}
|
||||||
duration={message.contentData.duration}
|
duration={message.contentData.duration}
|
||||||
width={message.contentData.width}
|
width={message.contentData.width}
|
||||||
height={message.contentData.height}
|
height={message.contentData.height}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import type { ExportContactType } from '../../../../shared/export'
|
||||||
import type { Contact, SelfInfo } from './exportTypes'
|
import type { Contact, SelfInfo } from './exportTypes'
|
||||||
import { displayName } from './exportUtils'
|
import { displayName } from './exportUtils'
|
||||||
|
|
||||||
@@ -8,6 +9,9 @@ interface ExportContactPanelProps {
|
|||||||
activeContact: Contact | null
|
activeContact: Contact | null
|
||||||
selectedContactIds: string[]
|
selectedContactIds: string[]
|
||||||
selectionMode: boolean
|
selectionMode: boolean
|
||||||
|
exportAll: boolean
|
||||||
|
allContactTypes: ExportContactType[]
|
||||||
|
exportRunning: boolean
|
||||||
selectionLimit: number
|
selectionLimit: number
|
||||||
selfInfo: SelfInfo | null
|
selfInfo: SelfInfo | null
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
@@ -17,6 +21,8 @@ interface ExportContactPanelProps {
|
|||||||
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
||||||
onSelectContact: (contact: Contact) => void
|
onSelectContact: (contact: Contact) => void
|
||||||
onCompleteSelection: () => void
|
onCompleteSelection: () => void
|
||||||
|
onExportAll: () => void
|
||||||
|
onToggleAllContactType: (type: ExportContactType) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +32,9 @@ export function ExportContactPanel({
|
|||||||
activeContact,
|
activeContact,
|
||||||
selectedContactIds,
|
selectedContactIds,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
|
exportAll,
|
||||||
|
allContactTypes,
|
||||||
|
exportRunning,
|
||||||
selectionLimit,
|
selectionLimit,
|
||||||
selfInfo,
|
selfInfo,
|
||||||
dbReady,
|
dbReady,
|
||||||
@@ -35,8 +44,16 @@ export function ExportContactPanel({
|
|||||||
onContactTypeChange,
|
onContactTypeChange,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onCompleteSelection,
|
onCompleteSelection,
|
||||||
|
onExportAll,
|
||||||
|
onToggleAllContactType,
|
||||||
onOpenSettings
|
onOpenSettings
|
||||||
}: ExportContactPanelProps): React.ReactElement {
|
}: 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 (
|
return (
|
||||||
<aside className="export-contact-panel">
|
<aside className="export-contact-panel">
|
||||||
<div className="export-panel-header">
|
<div className="export-panel-header">
|
||||||
@@ -71,9 +88,55 @@ export function ExportContactPanel({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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">
|
<div className="export-multi-select-bar">
|
||||||
<span>
|
<span>
|
||||||
已选 {selectedContactIds.length} / {selectionLimit} 个
|
已选 {selectedContactIds.length} / {selectionLimit} 个
|
||||||
@@ -82,21 +145,24 @@ export function ExportContactPanel({
|
|||||||
完成
|
完成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<div className="export-contact-list">
|
<div className="export-contact-list">
|
||||||
{filteredContacts.map((contact) => {
|
{filteredContacts.map((contact) => {
|
||||||
const name = displayName(contact)
|
const name = displayName(contact)
|
||||||
const selected = selectedContactIds.includes(contact.md5)
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={contact.md5}
|
key={contact.md5}
|
||||||
type="button"
|
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)}
|
onClick={() => onSelectContact(contact)}
|
||||||
disabled={atLimit}
|
disabled={atLimit}
|
||||||
aria-pressed={selected}
|
aria-pressed={visuallySelected}
|
||||||
>
|
>
|
||||||
<span className="export-contact-avatar">
|
<span className="export-contact-avatar">
|
||||||
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||||
@@ -105,7 +171,7 @@ export function ExportContactPanel({
|
|||||||
<strong>{name}</strong>
|
<strong>{name}</strong>
|
||||||
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||||
</span>
|
</span>
|
||||||
{selectionMode && (
|
{!exportAll && selectionMode && (
|
||||||
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
|
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
|
||||||
{selected ? '✓' : ''}
|
{selected ? '✓' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface ExportPreviewPanelProps {
|
|||||||
includeVoiceTranscripts: boolean
|
includeVoiceTranscripts: boolean
|
||||||
zip: boolean
|
zip: boolean
|
||||||
selectedCount: number
|
selectedCount: number
|
||||||
|
allExport: boolean
|
||||||
jobId: string
|
jobId: string
|
||||||
onCancel: (jobId: string) => void
|
onCancel: (jobId: string) => void
|
||||||
onReveal: (path: string) => void
|
onReveal: (path: string) => void
|
||||||
@@ -28,6 +29,7 @@ export function ExportPreviewPanel({
|
|||||||
includeVoiceTranscripts,
|
includeVoiceTranscripts,
|
||||||
zip,
|
zip,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
|
allExport,
|
||||||
jobId,
|
jobId,
|
||||||
onCancel,
|
onCancel,
|
||||||
onReveal
|
onReveal
|
||||||
@@ -61,6 +63,9 @@ export function ExportPreviewPanel({
|
|||||||
: phase === 'parsing'
|
: phase === 'parsing'
|
||||||
? `正在解析消息内容... ${percent}%`
|
? `正在解析消息内容... ${percent}%`
|
||||||
: `正在读取消息... ${percent}%`
|
: `正在读取消息... ${percent}%`
|
||||||
|
const currentTargetText = progress?.currentTargetName
|
||||||
|
? `第 ${progress.currentTargetIndex || 1}/${progress.currentTargetCount || selectedCount} 个:${progress.currentTargetName}`
|
||||||
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
||||||
@@ -69,23 +74,39 @@ export function ExportPreviewPanel({
|
|||||||
<div className="export-preview-heading">
|
<div className="export-preview-heading">
|
||||||
<strong>导出预览</strong>
|
<strong>导出预览</strong>
|
||||||
<span>
|
<span>
|
||||||
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
|
{allExport
|
||||||
|
? `${selectedCount} 个聊天 · 分目录导出`
|
||||||
|
: selectedCount > 1
|
||||||
|
? `${selectedCount} 个聊天 · 合并预览`
|
||||||
|
: '仅预览最近 20 条'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="export-message-preview">
|
<div className="export-message-preview">
|
||||||
<div className="export-preview-date">最近消息</div>
|
<div className="export-preview-date">{allExport ? '全量归档' : '最近消息'}</div>
|
||||||
{(previewItems.length
|
{(allExport
|
||||||
? previewItems
|
? [
|
||||||
: [
|
|
||||||
{
|
{
|
||||||
id: 'empty',
|
id: 'all-export',
|
||||||
from: 'user',
|
from: 'system',
|
||||||
content: '导出预览将在这里显示',
|
content: '每个聊天将保存为独立 HTML 档案',
|
||||||
type: '文字',
|
type: '系统消息',
|
||||||
datetime: '',
|
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) => (
|
).map((message) => (
|
||||||
<div
|
<div
|
||||||
key={`${message.exportConversationId || 'single'}:${message.id}`}
|
key={`${message.exportConversationId || 'single'}:${message.id}`}
|
||||||
@@ -146,6 +167,12 @@ export function ExportPreviewPanel({
|
|||||||
<div className="export-job-state">
|
<div className="export-job-state">
|
||||||
<h2>正在导出</h2>
|
<h2>正在导出</h2>
|
||||||
<p>导出任务在后台运行,不影响档案浏览。</p>
|
<p>导出任务在后台运行,不影响档案浏览。</p>
|
||||||
|
{currentTargetText && (
|
||||||
|
<div className="export-current-target">
|
||||||
|
<span>{progress?.currentTargetType === 'group' ? '群聊' : '联系人'}</span>
|
||||||
|
<strong>{currentTargetText}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ol>
|
<ol>
|
||||||
<li className="done">准备导出</li>
|
<li className="done">准备导出</li>
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
@@ -197,7 +224,7 @@ export function ExportPreviewPanel({
|
|||||||
className="export-primary-button"
|
className="export-primary-button"
|
||||||
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
||||||
>
|
>
|
||||||
打开档案
|
{allExport ? '打开导出目录' : '打开档案'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taskDetail = (task: ExportTaskRecord): string | null => {
|
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') {
|
if (task.status === 'completed') {
|
||||||
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
|
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
|
||||||
}
|
}
|
||||||
@@ -42,12 +45,13 @@ export function ExportTaskCenter({
|
|||||||
|
|
||||||
const copyTaskLog = async (task: ExportTaskRecord): Promise<void> => {
|
const copyTaskLog = async (task: ExportTaskRecord): Promise<void> => {
|
||||||
const log = [
|
const log = [
|
||||||
'WechatExplorer 导出任务日志',
|
'TraceMemo 导出任务日志',
|
||||||
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
|
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
|
||||||
`会话:${task.targetLabel}`,
|
`会话:${task.targetLabel}`,
|
||||||
`格式:${task.format.toUpperCase()}`,
|
`格式:${task.format.toUpperCase()}`,
|
||||||
`状态:${task.progress.phase}`,
|
`状态:${task.progress.phase}`,
|
||||||
`进度:${task.progress.percent ?? 0}%`,
|
`进度:${task.progress.percent ?? 0}%`,
|
||||||
|
`当前聊天:${task.progress.currentTargetName || '未开始'}`,
|
||||||
`错误:${task.progress.error || '未记录具体错误'}`
|
`错误:${task.progress.error || '未记录具体错误'}`
|
||||||
].join('\n')
|
].join('\n')
|
||||||
await navigator.clipboard.writeText(log)
|
await navigator.clipboard.writeText(log)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import type { Message } from '../../../../shared/types'
|
import type { Message } from '../../../../shared/types'
|
||||||
import type {
|
import type {
|
||||||
|
ExportContactType,
|
||||||
ExportJobProgress,
|
ExportJobProgress,
|
||||||
ExportMessageKind,
|
ExportMessageKind,
|
||||||
ExportNameMode,
|
ExportNameMode,
|
||||||
@@ -20,6 +21,11 @@ import type {
|
|||||||
} from './exportTypes'
|
} from './exportTypes'
|
||||||
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
|
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
|
||||||
import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
|
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({
|
export function ExportWorkspace({
|
||||||
contacts,
|
contacts,
|
||||||
@@ -33,17 +39,26 @@ export function ExportWorkspace({
|
|||||||
onCancelExport
|
onCancelExport
|
||||||
}: ExportWorkspaceProps): React.ReactElement {
|
}: ExportWorkspaceProps): React.ReactElement {
|
||||||
const initialSelection = initialContact || contacts[0] || null
|
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 initialContactRef = React.useRef<Contact | null>(initialSelection)
|
||||||
const previewLoadingRef = React.useRef(new Set<string>())
|
const previewLoadingRef = React.useRef(new Set<string>())
|
||||||
const [contactFilter, setContactFilter] = useState('')
|
const [contactFilter, setContactFilter] = useState('')
|
||||||
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||||
const [selectionMode, setSelectionMode] = useState(false)
|
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[]>(() =>
|
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
|
||||||
initialSelection ? [initialSelection] : []
|
initialSelection ? [initialSelection] : []
|
||||||
)
|
)
|
||||||
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
|
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
|
||||||
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
|
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 [startDate, setStartDate] = useState('')
|
||||||
const [endDate, setEndDate] = useState('')
|
const [endDate, setEndDate] = useState('')
|
||||||
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
||||||
@@ -57,8 +72,8 @@ export function ExportWorkspace({
|
|||||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||||
const [keepMissing, setKeepMissing] = useState(true)
|
const [keepMissing, setKeepMissing] = useState(true)
|
||||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
|
||||||
const [zip, setZip] = useState(false)
|
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
|
||||||
const [fileName, setFileName] = useState('')
|
const [fileName, setFileName] = useState('')
|
||||||
const [status, setStatus] = useState<ExportStatus>('idle')
|
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||||
const [jobId, setJobId] = useState('')
|
const [jobId, setJobId] = useState('')
|
||||||
@@ -80,6 +95,7 @@ export function ExportWorkspace({
|
|||||||
}, [contacts, initialContact, selectedContacts.length])
|
}, [contacts, initialContact, selectedContacts.length])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (exportAll) return
|
||||||
for (const contact of selectedContacts) {
|
for (const contact of selectedContacts) {
|
||||||
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
|
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
|
||||||
previewLoadingRef.current.add(contact.md5)
|
previewLoadingRef.current.add(contact.md5)
|
||||||
@@ -88,7 +104,7 @@ export function ExportWorkspace({
|
|||||||
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
|
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [loadPreviewMessages, previewByContact, selectedContacts])
|
}, [exportAll, loadPreviewMessages, previewByContact, selectedContacts])
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
const keyword = contactFilter.trim().toLowerCase()
|
const keyword = contactFilter.trim().toLowerCase()
|
||||||
@@ -105,31 +121,48 @@ export function ExportWorkspace({
|
|||||||
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
|
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
|
||||||
selectedContacts[0] ||
|
selectedContacts[0] ||
|
||||||
null
|
null
|
||||||
const selectedTargetKey = selectedContacts
|
const exportContacts = exportAll
|
||||||
.map((contact) => contact.md5)
|
? contacts.filter((contact) => allContactTypes.includes(contact.type))
|
||||||
.sort()
|
: selectedContacts
|
||||||
.join('|')
|
const selectedTargetKey = exportAll
|
||||||
const currentTask = exportTasks.find(
|
? ''
|
||||||
(task) => [...task.targetIds].sort().join('|') === selectedTargetKey
|
: 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 taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||||
const activeName = displayName(activeContact)
|
const activeName = displayName(activeContact)
|
||||||
const selectedNames = selectedContacts.map(displayName)
|
const selectedNames = selectedContacts.map(displayName)
|
||||||
const selectedLabel =
|
const allGroupCount = exportContacts.filter((contact) => contact.type === 'group').length
|
||||||
selectedNames.length > 1
|
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.join('、')} · 共 ${selectedNames.length} 个聊天`
|
||||||
: selectedNames[0] || '未选择聊天'
|
: selectedNames[0] || '未选择聊天'
|
||||||
const preview = selectedContacts
|
const preview = exportAll
|
||||||
.flatMap((contact) =>
|
? []
|
||||||
(previewByContact[contact.md5] || []).map((message) => ({
|
: selectedContacts
|
||||||
...message,
|
.flatMap((contact) =>
|
||||||
exportConversationId: contact.md5,
|
(previewByContact[contact.md5] || []).map((message) => ({
|
||||||
exportConversationName: displayName(contact),
|
...message,
|
||||||
exportConversationAvatarUrl: contact.avatar
|
exportConversationId: contact.md5,
|
||||||
}))
|
exportConversationName: displayName(contact),
|
||||||
)
|
exportConversationAvatarUrl: contact.avatar
|
||||||
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
|
}))
|
||||||
.slice(-20)
|
)
|
||||||
|
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
|
||||||
|
.slice(-20)
|
||||||
const previewMediaCount = preview.filter(
|
const previewMediaCount = preview.filter(
|
||||||
(message) =>
|
(message) =>
|
||||||
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
|
['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),
|
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
const defaultOutputName =
|
const defaultOutputName = exportAll
|
||||||
selectedContacts.length > 1
|
? '全部聊天记录'
|
||||||
|
: selectedContacts.length > 1
|
||||||
? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案`
|
? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案`
|
||||||
: `${activeName}_聊天档案`
|
: `${activeName}_聊天档案`
|
||||||
const outputName = fileName.trim() || defaultOutputName
|
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'
|
(contact) => contact.type === 'group'
|
||||||
)
|
)
|
||||||
? [
|
? [
|
||||||
@@ -170,6 +204,10 @@ export function ExportWorkspace({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const handleSelectContact = (contact: Contact): void => {
|
const handleSelectContact = (contact: Contact): void => {
|
||||||
|
if (exportAll) {
|
||||||
|
setExportAll(false)
|
||||||
|
setRange('today')
|
||||||
|
}
|
||||||
if (!selectionMode) {
|
if (!selectionMode) {
|
||||||
setSelectedContacts([contact])
|
setSelectedContacts([contact])
|
||||||
setActiveContactId(contact.md5)
|
setActiveContactId(contact.md5)
|
||||||
@@ -193,6 +231,26 @@ export function ExportWorkspace({
|
|||||||
setStatus('idle')
|
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(() => {
|
React.useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
void window.api
|
void window.api
|
||||||
@@ -214,10 +272,11 @@ export function ExportWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStart = async (): Promise<void> => {
|
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.
|
// Runs only from the export button event; a fresh id is required for each job.
|
||||||
const nextJobId = `export-${Date.now()}`
|
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 =
|
const shouldIncludeVoiceTranscripts =
|
||||||
includeVoiceTranscripts &&
|
includeVoiceTranscripts &&
|
||||||
includeMedia &&
|
includeMedia &&
|
||||||
@@ -226,31 +285,28 @@ export function ExportWorkspace({
|
|||||||
voiceModelStatus?.state === 'ready'
|
voiceModelStatus?.state === 'ready'
|
||||||
setJobId(nextJobId)
|
setJobId(nextJobId)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip })
|
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip: shouldZip })
|
||||||
setStatus('running')
|
setStatus('running')
|
||||||
const targets: ExportTarget[] = await Promise.all(
|
const targets: ExportTarget[] = await Promise.all(
|
||||||
selectedContacts.map(async (contact) => {
|
exportContacts.map(async (contact) => {
|
||||||
const nameMap: Record<string, string> = {}
|
const nameMap: Record<string, string> = {}
|
||||||
const avatarUrls: Record<string, string> = {}
|
const avatarUrls: Record<string, string> = {}
|
||||||
if (contact.type === 'group') {
|
if (contact.type === 'group') {
|
||||||
const snapshot = await window.api.getGroupSnapshot(contact.md5)
|
if (!exportAll) {
|
||||||
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
|
const snapshot = await window.api.getGroupSnapshot(contact.md5)
|
||||||
nameMap[member.wxid] =
|
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
|
||||||
nameMode === 'groupNickname'
|
nameMap[member.wxid] = resolveMemberName(member, nameMode)
|
||||||
? member.groupNickname || member.nickname || member.wxid
|
if (member.avatar) avatarUrls[member.wxid] = member.avatar
|
||||||
: nameMode === 'remark'
|
}
|
||||||
? member.remark || member.wechatNickname || member.wxid
|
|
||||||
: member.wechatNickname || member.wxid
|
|
||||||
if (member.avatar) avatarUrls[member.wxid] = member.avatar
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!exportAll) {
|
||||||
nameMap[contact.m_nsUsrName] =
|
nameMap[contact.m_nsUsrName] =
|
||||||
nameMode === 'remark'
|
nameMode === 'remark'
|
||||||
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
|
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
|
||||||
: contact.wechatNickname || contact.m_nsUsrName
|
: contact.wechatNickname || contact.m_nsUsrName
|
||||||
if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar
|
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
|
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||||
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
|
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
|
||||||
}
|
}
|
||||||
@@ -273,19 +329,25 @@ export function ExportWorkspace({
|
|||||||
: null
|
: null
|
||||||
const request: ExportRequest = {
|
const request: ExportRequest = {
|
||||||
jobId: nextJobId,
|
jobId: nextJobId,
|
||||||
|
scope: exportAll ? 'all' : 'selected',
|
||||||
|
allContactTypes: exportAll ? allContactTypes : undefined,
|
||||||
targets,
|
targets,
|
||||||
format: exportFormat,
|
format: exportFormat,
|
||||||
outputName,
|
outputName,
|
||||||
startTime: startOfRange
|
startTime: exportAll
|
||||||
? Math.floor(startOfRange.getTime() / 1000)
|
? undefined
|
||||||
: range === 'custom' && startDate
|
: startOfRange
|
||||||
? Math.floor(new Date(startDate).getTime() / 1000)
|
? Math.floor(startOfRange.getTime() / 1000)
|
||||||
: undefined,
|
: range === 'custom' && startDate
|
||||||
endTime: startOfRange
|
? Math.floor(new Date(startDate).getTime() / 1000)
|
||||||
? Math.floor(endOfToday.getTime() / 1000)
|
: undefined,
|
||||||
: range === 'custom' && endDate
|
endTime: exportAll
|
||||||
? Math.floor(new Date(endDate).getTime() / 1000)
|
? undefined
|
||||||
: undefined,
|
: startOfRange
|
||||||
|
? Math.floor(endOfToday.getTime() / 1000)
|
||||||
|
: range === 'custom' && endDate
|
||||||
|
? Math.floor(new Date(endDate).getTime() / 1000)
|
||||||
|
: undefined,
|
||||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||||
includeMedia,
|
includeMedia,
|
||||||
includeVoiceTranscripts: shouldIncludeVoiceTranscripts,
|
includeVoiceTranscripts: shouldIncludeVoiceTranscripts,
|
||||||
@@ -293,7 +355,7 @@ export function ExportWorkspace({
|
|||||||
fallbackThumbnail,
|
fallbackThumbnail,
|
||||||
keepMissing,
|
keepMissing,
|
||||||
includeAvatars,
|
includeAvatars,
|
||||||
zip
|
zip: shouldZip
|
||||||
}
|
}
|
||||||
const result = await onStartExport(request)
|
const result = await onStartExport(request)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -330,6 +392,8 @@ export function ExportWorkspace({
|
|||||||
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
|
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
|
||||||
zip: currentTask.zip === true
|
zip: currentTask.zip === true
|
||||||
})
|
})
|
||||||
|
setFormat(currentTask.format)
|
||||||
|
setZip(currentTask.zip === true)
|
||||||
setStatus(
|
setStatus(
|
||||||
currentTask.status === 'running'
|
currentTask.status === 'running'
|
||||||
? 'running'
|
? 'running'
|
||||||
@@ -344,6 +408,8 @@ export function ExportWorkspace({
|
|||||||
setSelectedContacts(contact ? [contact] : [])
|
setSelectedContacts(contact ? [contact] : [])
|
||||||
setActiveContactId(contact?.md5 || '')
|
setActiveContactId(contact?.md5 || '')
|
||||||
setSelectionMode(false)
|
setSelectionMode(false)
|
||||||
|
setExportAll(false)
|
||||||
|
setAllContactTypes([...ALL_CONTACT_TYPES])
|
||||||
setRange('today')
|
setRange('today')
|
||||||
setStartDate('')
|
setStartDate('')
|
||||||
setEndDate('')
|
setEndDate('')
|
||||||
@@ -363,12 +429,15 @@ export function ExportWorkspace({
|
|||||||
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
|
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPath =
|
const targetPath = exportAll
|
||||||
format === 'html'
|
? format === 'html' && zip
|
||||||
|
? `文稿/TraceMemo/导出/${outputName}.zip`
|
||||||
|
: `文稿/TraceMemo/导出/${outputName}/`
|
||||||
|
: format === 'html'
|
||||||
? zip
|
? zip
|
||||||
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
? `文稿/TraceMemo/导出/${outputName}.zip`
|
||||||
: `文稿/WechatExplorer/导出/${outputName}/`
|
: `文稿/TraceMemo/导出/${outputName}/`
|
||||||
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
|
: `文稿/TraceMemo/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="export-workspace">
|
<div className="export-workspace">
|
||||||
@@ -378,6 +447,9 @@ export function ExportWorkspace({
|
|||||||
activeContact={activeContact}
|
activeContact={activeContact}
|
||||||
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
|
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
|
exportAll={exportAll}
|
||||||
|
allContactTypes={allContactTypes}
|
||||||
|
exportRunning={status === 'running'}
|
||||||
selectionLimit={selectionLimit}
|
selectionLimit={selectionLimit}
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
@@ -387,6 +459,8 @@ export function ExportWorkspace({
|
|||||||
onContactTypeChange={setContactType}
|
onContactTypeChange={setContactType}
|
||||||
onSelectContact={handleSelectContact}
|
onSelectContact={handleSelectContact}
|
||||||
onCompleteSelection={() => setSelectionMode(false)}
|
onCompleteSelection={() => setSelectionMode(false)}
|
||||||
|
onExportAll={handleExportAll}
|
||||||
|
onToggleAllContactType={toggleAllContactType}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -401,15 +475,24 @@ export function ExportWorkspace({
|
|||||||
/>
|
/>
|
||||||
<header className="export-config-header">
|
<header className="export-config-header">
|
||||||
<span className="export-chat-avatar-stack" aria-hidden>
|
<span className="export-chat-avatar-stack" aria-hidden>
|
||||||
{selectedContacts.slice(0, 3).map((contact) => (
|
{exportAll
|
||||||
<span className="export-chat-avatar" key={contact.md5}>
|
? allContactTypes.map((type) => (
|
||||||
{contact.avatar ? (
|
<span
|
||||||
<img src={contact.avatar} alt="" />
|
className={`export-chat-avatar export-all-chat-avatar ${type}`}
|
||||||
) : (
|
key={type}
|
||||||
displayName(contact).slice(0, 1)
|
>
|
||||||
)}
|
{type === 'group' ? '群' : '联'}
|
||||||
</span>
|
</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>
|
||||||
<span className="export-config-title">
|
<span className="export-config-title">
|
||||||
<h1>导出设置</h1>
|
<h1>导出设置</h1>
|
||||||
@@ -418,9 +501,13 @@ export function ExportWorkspace({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="export-add-chat-button"
|
className="export-add-chat-button"
|
||||||
onClick={() => setSelectionMode((current) => !current)}
|
disabled={exportAll}
|
||||||
|
onClick={() => {
|
||||||
|
setExportAll(false)
|
||||||
|
setSelectionMode((current) => !current)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selectionMode ? '完成选择' : '+ 添加聊天'}
|
{exportAll ? '已选择全部聊天' : selectionMode ? '完成选择' : '+ 添加聊天'}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -432,7 +519,7 @@ export function ExportWorkspace({
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
className={format === value ? 'active' : ''}
|
className={format === value ? 'active' : ''}
|
||||||
disabled={selectedContacts.length > 1 && value !== 'html'}
|
disabled={!exportAll && exportContacts.length > 1 && value !== 'html'}
|
||||||
onClick={() => setFormat(value)}
|
onClick={() => setFormat(value)}
|
||||||
>
|
>
|
||||||
<strong>{formatLabels[value].label}</strong>
|
<strong>{formatLabels[value].label}</strong>
|
||||||
@@ -441,9 +528,11 @@ export function ExportWorkspace({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="export-helper-text">
|
<p className="export-helper-text">
|
||||||
{selectedContacts.length > 1
|
{exportAll
|
||||||
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
|
? '全部导出固定使用全部时间;每个群聊或联系人都会在自己的目录中生成所选格式的独立档案。'
|
||||||
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
|
: selectedContacts.length > 1
|
||||||
|
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
|
||||||
|
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
|
||||||
</p>
|
</p>
|
||||||
{format === 'html' && (
|
{format === 'html' && (
|
||||||
<>
|
<>
|
||||||
@@ -482,34 +571,45 @@ export function ExportWorkspace({
|
|||||||
<div className="export-range-toggle">
|
<div className="export-range-toggle">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={range === 'today' ? 'active' : ''}
|
className={range === 'all' ? 'active' : ''}
|
||||||
onClick={() => setRange('today')}
|
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>
|
</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>
|
</div>
|
||||||
{range === 'custom' && (
|
{!exportAll && range === 'custom' && (
|
||||||
<div className="export-date-fields">
|
<div className="export-date-fields">
|
||||||
<label>
|
<label>
|
||||||
开始时间
|
开始时间
|
||||||
@@ -683,7 +783,7 @@ export function ExportWorkspace({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="export-primary-button"
|
className="export-primary-button"
|
||||||
disabled={!activeContact || status === 'running'}
|
disabled={!activeContact || !exportContacts.length || status === 'running'}
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
>
|
>
|
||||||
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
||||||
@@ -700,7 +800,8 @@ export function ExportWorkspace({
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
|
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
|
||||||
zip={activeJobOptions.zip}
|
zip={activeJobOptions.zip}
|
||||||
selectedCount={selectedContacts.length}
|
selectedCount={exportContacts.length}
|
||||||
|
allExport={exportAll}
|
||||||
jobId={jobId}
|
jobId={jobId}
|
||||||
onCancel={(exportJobId) => {
|
onCancel={(exportJobId) => {
|
||||||
void window.api.cancelExport(exportJobId)
|
void window.api.cancelExport(exportJobId)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
ExportTaskRecord
|
ExportTaskRecord
|
||||||
} from '../../../../shared/export'
|
} 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 ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||||
export type ExportStatus = 'idle' | 'running' | 'completed'
|
export type ExportStatus = 'idle' | 'running' | 'completed'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Contact, Message } from '../../../../shared/types'
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
import type { ExportFormat, GroupMemberName } from './exportTypes'
|
import type { ExportFormat, GroupMemberName } from './exportTypes'
|
||||||
|
import { resolveMemberName } from '../../../../shared/member-names'
|
||||||
|
|
||||||
export const messageKinds = [
|
export const messageKinds = [
|
||||||
['text', '文字'],
|
['text', '文字'],
|
||||||
@@ -43,13 +44,10 @@ export function buildNameMap(
|
|||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (activeContact?.type === 'group') {
|
if (activeContact?.type === 'group') {
|
||||||
for (const member of groupMembers) {
|
for (const member of groupMembers) {
|
||||||
const value =
|
map[member.wxid] = resolveMemberName(
|
||||||
nameMode === 'groupNickname'
|
member,
|
||||||
? member.groupNickname || member.nickname || member.wxid
|
nameMode as 'groupNickname' | 'remark' | 'wechatNickname'
|
||||||
: nameMode === 'remark'
|
)
|
||||||
? member.remark || member.wechatNickname || member.wxid
|
|
||||||
: member.wechatNickname || member.wxid
|
|
||||||
map[member.wxid] = value
|
|
||||||
}
|
}
|
||||||
} else if (activeContact) {
|
} else if (activeContact) {
|
||||||
map[activeContact.m_nsUsrName] =
|
map[activeContact.m_nsUsrName] =
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface AppShellProps {
|
|||||||
|
|
||||||
function BrandLogo(): React.ReactElement {
|
function BrandLogo(): React.ReactElement {
|
||||||
return (
|
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" />
|
<img src={brandIcon} alt="" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ModelSummary } from './ModelSummary'
|
|||||||
import { ReportDensitySelector } from './ReportDensitySelector'
|
import { ReportDensitySelector } from './ReportDensitySelector'
|
||||||
import { ReportRangeSelector } from './ReportRangeSelector'
|
import { ReportRangeSelector } from './ReportRangeSelector'
|
||||||
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
||||||
|
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
|
||||||
import { ReportSectionSelector } from './ReportSectionSelector'
|
import { ReportSectionSelector } from './ReportSectionSelector'
|
||||||
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
|
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ export function AiReportWorkspace({
|
|||||||
onChange={onMemberNamePreferenceChange}
|
onChange={onMemberNamePreferenceChange}
|
||||||
disabled={configDisabled}
|
disabled={configDisabled}
|
||||||
/>
|
/>
|
||||||
|
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
|
||||||
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
||||||
<section className="report-config-section report-timeout-section">
|
<section className="report-config-section report-timeout-section">
|
||||||
<div>
|
<div>
|
||||||
@@ -192,7 +194,7 @@ export function AiReportWorkspace({
|
|||||||
<h3>隐私说明</h3>
|
<h3>隐私说明</h3>
|
||||||
<p>微信数据库和聊天记录默认从本机读取。</p>
|
<p>微信数据库和聊天记录默认从本机读取。</p>
|
||||||
<p>所选内容将发送至你配置的模型服务进行处理。</p>
|
<p>所选内容将发送至你配置的模型服务进行处理。</p>
|
||||||
<p>WechatExplorer 本身不额外保存或转发内容。</p>
|
<p>TraceMemo 本身不额外保存或转发内容。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{generatedImage && (
|
{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 React, { useEffect, useState } from 'react'
|
||||||
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
import {
|
||||||
|
REPORT_TASK_STEPS,
|
||||||
|
ReportGenerationPhase,
|
||||||
|
VoiceTranscriptionProgress
|
||||||
|
} from '../../hooks/useGroupReportGeneration'
|
||||||
|
|
||||||
interface ReportTaskStatusPanelProps {
|
interface ReportTaskStatusPanelProps {
|
||||||
phase: ReportGenerationPhase
|
phase: ReportGenerationPhase
|
||||||
error: string
|
error: string
|
||||||
|
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
|
||||||
|
voiceTranscriptionEnabled: boolean
|
||||||
onRetry: () => void
|
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({
|
export function ReportTaskStatusPanel({
|
||||||
phase,
|
phase,
|
||||||
error,
|
error,
|
||||||
|
voiceTranscriptionProgress,
|
||||||
|
voiceTranscriptionEnabled,
|
||||||
onRetry
|
onRetry
|
||||||
}: ReportTaskStatusPanelProps): React.ReactElement {
|
}: 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 completedAll = phase === 'success'
|
||||||
const [logPath, setLogPath] = useState('')
|
const [logPath, setLogPath] = useState('')
|
||||||
|
|
||||||
@@ -46,12 +44,12 @@ export function ReportTaskStatusPanel({
|
|||||||
: phase === 'error'
|
: phase === 'error'
|
||||||
? '生成失败'
|
? '生成失败'
|
||||||
: activeIndex >= 0
|
: activeIndex >= 0
|
||||||
? `${activeIndex + 1}/${TASK_STEPS.length}`
|
? `${activeIndex + 1}/${taskSteps.length}`
|
||||||
: '等待开始'}
|
: '等待开始'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="report-task-steps">
|
<div className="report-task-steps">
|
||||||
{TASK_STEPS.map((step, index) => {
|
{taskSteps.map((step, index) => {
|
||||||
const state =
|
const state =
|
||||||
completedAll || (activeIndex >= 0 && index < activeIndex)
|
completedAll || (activeIndex >= 0 && index < activeIndex)
|
||||||
? 'done'
|
? 'done'
|
||||||
@@ -71,6 +69,25 @@ export function ReportTaskStatusPanel({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</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' && (
|
{phase === 'error' && (
|
||||||
<div className="report-task-error">
|
<div className="report-task-error">
|
||||||
<b>错误摘要</b>
|
<b>错误摘要</b>
|
||||||
|
|||||||
@@ -1094,7 +1094,7 @@ export function AISearchWorkspace({
|
|||||||
<div className="ai-search-workspace">
|
<div className="ai-search-workspace">
|
||||||
<header className="ai-search-header">
|
<header className="ai-search-header">
|
||||||
<div>
|
<div>
|
||||||
<span className="ai-search-kicker">WechatExplorer · LOCAL INTELLIGENCE</span>
|
<span className="ai-search-kicker">TraceMemo · LOCAL INTELLIGENCE</span>
|
||||||
<h1>问问你的微信</h1>
|
<h1>问问你的微信</h1>
|
||||||
<p>在本地聊天记录中提炼主题、结论和可追溯证据</p>
|
<p>在本地聊天记录中提炼主题、结论和可追溯证据</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1392,6 +1392,13 @@ export function AISearchWorkspace({
|
|||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
void runAnalysis()
|
||||||
|
}}
|
||||||
placeholder="例如:技术交流群最近讨论了哪些 Windows 性能问题?"
|
placeholder="例如:技术交流群最近讨论了哪些 Windows 性能问题?"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function AgentHubWorkspace(): React.ReactElement {
|
|||||||
<div className="agent-hub-workspace">
|
<div className="agent-hub-workspace">
|
||||||
<header className="agent-hub-header">
|
<header className="agent-hub-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="agent-hub-eyebrow">WechatExplorer</div>
|
<div className="agent-hub-eyebrow">TraceMemo</div>
|
||||||
<h1>Agent Hub</h1>
|
<h1>Agent Hub</h1>
|
||||||
<p>让微信机器人安全调用聊天数据与 AI 能力。</p>
|
<p>让微信机器人安全调用聊天数据与 AI 能力。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +193,7 @@ export function AgentHubWorkspace(): React.ReactElement {
|
|||||||
<aside className="agent-hub-card agent-hub-capability-card">
|
<aside className="agent-hub-card agent-hub-capability-card">
|
||||||
<span className="agent-hub-card-kicker">已启用能力</span>
|
<span className="agent-hub-card-kicker">已启用能力</span>
|
||||||
<h2>微信数据助手</h2>
|
<h2>微信数据助手</h2>
|
||||||
<p>机器人通过本机 Agent Hub 调用 WechatExplorer,不向公网暴露数据库。</p>
|
<p>机器人通过本机 Agent Hub 调用 TraceMemo,不向公网暴露数据库。</p>
|
||||||
<div className="agent-hub-example">
|
<div className="agent-hub-example">
|
||||||
<span>支持自然语言,可以这样问</span>
|
<span>支持自然语言,可以这样问</span>
|
||||||
<strong>“最近 5 条消息是谁?”</strong>
|
<strong>“最近 5 条消息是谁?”</strong>
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export function ApiRuntimePanel({
|
|||||||
<h3>隐私说明</h3>
|
<h3>隐私说明</h3>
|
||||||
<p>
|
<p>
|
||||||
{localOnly
|
{localOnly
|
||||||
? '本地 API 默认监听 127.0.0.1。WechatExplorer 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
|
? '本地 API 默认监听 127.0.0.1。TraceMemo 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
|
||||||
: '当前服务并非仅本机访问。请确认局域网环境可信;API Token 不等同于公网安全防护。'}
|
: '当前服务并非仅本机访问。请确认局域网环境可信;API Token 不等同于公网安全防护。'}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ export function ReaderSkillOverview({
|
|||||||
<div className="api-workspace-heading">
|
<div className="api-workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<div className="api-title-line">
|
<div className="api-title-line">
|
||||||
<h1>WechatExplorer Reader</h1>
|
<h1>TraceMemo Reader</h1>
|
||||||
<span className={`api-skill-status ${skill?.available ? 'ready' : 'error'}`}>
|
<span className={`api-skill-status ${skill?.available ? 'ready' : 'error'}`}>
|
||||||
{skill?.available ? '已安装' : '文件不可用'}
|
{skill?.available ? '已安装' : '文件不可用'}
|
||||||
</span>
|
</span>
|
||||||
<span className="api-version">{skill?.version || 'v1.0'}</span>
|
<span className="api-version">{skill?.version || 'v1.0'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>通过本地 HTTP API 读取 WechatExplorer 已解锁的微信聊天数据</p>
|
<p>通过本地 HTTP API 读取 TraceMemo 已解锁的微信聊天数据</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="api-header-actions">
|
<div className="api-header-actions">
|
||||||
<button type="button" onClick={onPreview} disabled={!skill?.available}>
|
<button type="button" onClick={onPreview} disabled={!skill?.available}>
|
||||||
@@ -79,9 +79,9 @@ export function ReaderSkillOverview({
|
|||||||
<div className="api-introduction">
|
<div className="api-introduction">
|
||||||
<h2>能力简介</h2>
|
<h2>能力简介</h2>
|
||||||
<p>
|
<p>
|
||||||
WechatExplorer Reader 让本地 AI Agent
|
TraceMemo Reader 让本地 AI Agent
|
||||||
在用户授权和本地服务运行时,读取联系人、群聊、聊天记录和群成员信息,并调用内置模板导出群聊日报。聊天数据由
|
在用户授权和本地服务运行时,读取联系人、群聊、聊天记录和群成员信息,并调用内置模板导出群聊日报。聊天数据由
|
||||||
WechatExplorer 本地服务提供,不会由该 API 自动上传到其他服务器。
|
TraceMemo 本地服务提供,不会由该 API 自动上传到其他服务器。
|
||||||
</p>
|
</p>
|
||||||
<div className="api-flow">
|
<div className="api-flow">
|
||||||
<span>AI Agent</span>
|
<span>AI Agent</span>
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ export function SkillDetails({
|
|||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>名称</dt>
|
<dt>名称</dt>
|
||||||
<dd>wechatexplorer-reader</dd>
|
<dd>TraceMemo Reader</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>标识</dt>
|
||||||
|
<dd>tracememo-reader</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>版本</dt>
|
<dt>版本</dt>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function SkillInstallFlow({
|
|||||||
<section className={service?.running && dbReady ? 'done' : 'active'}>
|
<section className={service?.running && dbReady ? 'done' : 'active'}>
|
||||||
<b>1</b>
|
<b>1</b>
|
||||||
<div>
|
<div>
|
||||||
<h3>确认 WechatExplorer 已就绪</h3>
|
<h3>确认 TraceMemo 已就绪</h3>
|
||||||
<p>
|
<p>
|
||||||
本地 API:{service?.running ? '运行中' : '已停止'} · {address}
|
本地 API:{service?.running ? '运行中' : '已停止'} · {address}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export function SkillPreviewDialog({
|
|||||||
className="api-markdown-overlay"
|
className="api-markdown-overlay"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="WechatExplorer Reader Skill 预览"
|
aria-label="TraceMemo Reader Skill 预览"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<strong>WechatExplorer Reader</strong>
|
<strong>TraceMemo Reader</strong>
|
||||||
<span>{version || 'v1.0'}</span>
|
<span>{version || 'v1.0'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const REPORT_REQUEST_PRESET = JSON.stringify(
|
|||||||
activeUsers: 0,
|
activeUsers: 0,
|
||||||
timeSpan: '00:00-23:59',
|
timeSpan: '00:00-23:59',
|
||||||
generatedAt: '2026-07-13 22:00',
|
generatedAt: '2026-07-13 22:00',
|
||||||
recordNote: '本日报由 WechatExplorer 自动生成',
|
recordNote: '本日报由 TraceMemo 自动生成',
|
||||||
footerNote: '',
|
footerNote: '',
|
||||||
heroParticipants: [],
|
heroParticipants: [],
|
||||||
avatars: {},
|
avatars: {},
|
||||||
@@ -37,7 +37,7 @@ export const AGENT_GROUP_REPORT_PRESET = JSON.stringify(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const AGENT_SEND_PRESET = JSON.stringify(
|
export const AGENT_SEND_PRESET = JSON.stringify(
|
||||||
{ to: '', text: 'WechatExplorer Agent Hub 发送测试' },
|
{ to: '', text: 'TraceMemo Agent Hub 发送测试' },
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string }
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const githubLatestUrl =
|
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
|
export const githubVersionedInstallUrl: string | null = null
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ function requestHost(host: string): string {
|
|||||||
function opening(target: AgentInstallTarget): string {
|
function opening(target: AgentInstallTarget): string {
|
||||||
switch (target) {
|
switch (target) {
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return '请将本地目录中的 WechatExplorer Reader Skill 安装到当前 Codex 项目或用户 Skill 目录:'
|
return '请将本地目录中的 TraceMemo Reader Skill 安装到当前 Codex 项目或用户 Skill 目录:'
|
||||||
case 'claude-code':
|
case 'claude-code':
|
||||||
return '请安装以下本地 WechatExplorer Reader Skill,并按照 SKILL.md 调用本地 HTTP API:'
|
return '请安装以下本地 TraceMemo Reader Skill,并按照 SKILL.md 调用本地 HTTP API:'
|
||||||
case 'openclaw':
|
case 'openclaw':
|
||||||
return '请将以下本地目录作为 WechatExplorer Reader Skill 安装,并阅读其中的 SKILL.md:'
|
return '请将以下本地目录作为 TraceMemo Reader Skill 安装,并阅读其中的 SKILL.md:'
|
||||||
default:
|
default:
|
||||||
return '请读取并安装以下 WechatExplorer Reader Skill:'
|
return '请读取并安装以下 TraceMemo Reader Skill:'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +32,10 @@ export function buildSkillInstallInstruction({
|
|||||||
const sourceText =
|
const sourceText =
|
||||||
source.type === 'local'
|
source.type === 'local'
|
||||||
? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:`
|
? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:`
|
||||||
: `请从以下地址安装 WechatExplorer Reader Skill:\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:`
|
: `请从以下地址安装 TraceMemo 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。安装完成后告诉我验证结果。`
|
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 {
|
export function buildSkillVerificationPrompt(): string {
|
||||||
return '请检查 WechatExplorer Reader 是否已连接,然后列出最近 5 个微信会话。'
|
return '请检查 TraceMemo Reader 是否已连接,然后列出最近 5 个微信会话。'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function AIImageUnderstandingTest({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p className="ai-vision-privacy">
|
<p className="ai-vision-privacy">
|
||||||
图片只会发送给你配置的 AI 服务,不会上传到 WechatExplorer 的其他服务器,也不会写入本地缓存。
|
图片只会发送给你配置的 AI 服务,不会上传到 TraceMemo 的其他服务器,也不会写入本地缓存。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{state.error ? <p className="ai-vision-error">{state.error}</p> : null}
|
{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 className="database-key-auto-heading">
|
||||||
<div>
|
<div>
|
||||||
<strong>Windows 自动获取</strong>
|
<strong>Windows 自动获取</strong>
|
||||||
<p>WechatExplorer 可在微信桌面端正在运行时,通过本机内存扫描尝试获取数据库密钥。</p>
|
<p>TraceMemo 可在微信桌面端正在运行时,通过本机内存扫描尝试获取数据库密钥。</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function DatabaseKeyDangerZone({
|
|||||||
>
|
>
|
||||||
<h2 id="database-key-confirm-title">确认清除数据库密钥?</h2>
|
<h2 id="database-key-confirm-title">确认清除数据库密钥?</h2>
|
||||||
<p>
|
<p>
|
||||||
清除后 WechatExplorer
|
清除后 TraceMemo
|
||||||
将暂时无法读取聊天记录,需要重新输入或获取密钥。该操作不会删除微信原始数据。
|
将暂时无法读取聊天记录,需要重新输入或获取密钥。该操作不会删除微信原始数据。
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
@@ -99,7 +99,7 @@ export function DatabaseKeyDangerZone({
|
|||||||
>
|
>
|
||||||
<h2 id="database-key-return-confirm-title">返回登录界面?</h2>
|
<h2 id="database-key-return-confirm-title">返回登录界面?</h2>
|
||||||
<p>
|
<p>
|
||||||
WechatExplorer
|
TraceMemo
|
||||||
将断开当前数据库连接并回到密钥输入界面。已保存的数据库密钥和微信原始数据不会被删除。
|
将断开当前数据库连接并回到密钥输入界面。已保存的数据库密钥和微信原始数据不会被删除。
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function buildDatabaseKeyDiagnostics(
|
|||||||
): string {
|
): string {
|
||||||
const validation = state.validation
|
const validation = state.validation
|
||||||
return [
|
return [
|
||||||
'WechatExplorer 数据库密钥诊断',
|
'TraceMemo 数据库密钥诊断',
|
||||||
`已保存: ${state.saved ? '是' : '否'}`,
|
`已保存: ${state.saved ? '是' : '否'}`,
|
||||||
`已验证: ${validation?.success ? '是' : '否'}`,
|
`已验证: ${validation?.success ? '是' : '否'}`,
|
||||||
`密钥长度合法: ${isDatabaseKeyFormatValid(input) ? '是' : '否'}`,
|
`密钥长度合法: ${isDatabaseKeyFormatValid(input) ? '是' : '否'}`,
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ export function AboutPage({ onNotice }: { onNotice: (message: string) => void })
|
|||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>关于</h1>
|
<h1>关于</h1>
|
||||||
<p>WechatExplorer 本地微信聊天记录工作台。</p>
|
<p>TraceMemo(迹忆)本地优先、可追溯的 AI 微信知识与分析工作台。</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="settings-page-scroll">
|
<div className="settings-page-scroll">
|
||||||
<div className="settings-page-content">
|
<div className="settings-page-content">
|
||||||
<section className="settings-card about-identity-card">
|
<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>
|
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">GitHub 仓库</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ function formatBytes(value: number): string {
|
|||||||
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`
|
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 [summary, setSummary] = useState<CacheSummary | null>(null)
|
||||||
const [busyScope, setBusyScope] = useState<
|
const [busyScope, setBusyScope] = useState<
|
||||||
'bootstrap' | 'electron' | 'knowledge' | 'all' | 'local' | null
|
'bootstrap' | 'electron' | 'knowledge' | 'knowledge-directory' | 'all' | 'local' | null
|
||||||
>(null)
|
>(null)
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
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 (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
@@ -88,15 +105,28 @@ export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) =>
|
|||||||
<div>
|
<div>
|
||||||
<h3>{item.label}</h3>
|
<h3>{item.label}</h3>
|
||||||
<p>{item.description}</p>
|
<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>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busyScope !== null}
|
|
||||||
onClick={() => void clear(item.id)}
|
|
||||||
>
|
|
||||||
{busyScope === item.id ? '清理中...' : '清理'}
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
<section className="settings-card settings-cache-item">
|
<section className="settings-card settings-cache-item">
|
||||||
@@ -113,7 +143,9 @@ export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) =>
|
|||||||
|
|
||||||
<div className="settings-inline-note">
|
<div className="settings-inline-note">
|
||||||
<strong>说明</strong>
|
<strong>说明</strong>
|
||||||
<span>缓存没有过期时间,只有在这里手动清理,或应用检测到格式需要迁移时才会被替换。</span>
|
<span>
|
||||||
|
缓存没有过期时间,只有在这里手动清理,或应用检测到格式需要迁移时才会被替换。
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function DatabaseKeyPage({
|
|||||||
<div>
|
<div>
|
||||||
<strong>密钥仅保存在本机</strong>
|
<strong>密钥仅保存在本机</strong>
|
||||||
<p>
|
<p>
|
||||||
WechatExplorer
|
TraceMemo
|
||||||
使用数据库密钥读取本机微信数据库。密钥通过系统安全存储加密保存,不会写入普通日志,也不会上传到服务器。
|
使用数据库密钥读取本机微信数据库。密钥通过系统安全存储加密保存,不会写入普通日志,也不会上传到服务器。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function ImageDecryptionPage({
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>图片仅在本机解析</strong>
|
<strong>图片仅在本机解析</strong>
|
||||||
<p>WechatExplorer 不会上传您的微信图片。所有图片解析和缓存处理均在本地完成。</p>
|
<p>TraceMemo 不会上传您的微信图片。所有图片解析和缓存处理均在本地完成。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import {
|
|||||||
SummaryMessageType
|
SummaryMessageType
|
||||||
} from '../utils/group-report'
|
} from '../utils/group-report'
|
||||||
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
|
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_STEP_TIMEOUT_MS = 90_000
|
||||||
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
||||||
@@ -19,6 +27,7 @@ const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
|||||||
export type ReportGenerationPhase =
|
export type ReportGenerationPhase =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'loadingMessages'
|
| 'loadingMessages'
|
||||||
|
| 'transcribingVoice'
|
||||||
| 'preparingInput'
|
| 'preparingInput'
|
||||||
| 'requestingModel'
|
| 'requestingModel'
|
||||||
| 'exportingReport'
|
| 'exportingReport'
|
||||||
@@ -80,6 +89,7 @@ export interface ReportTaskStep {
|
|||||||
|
|
||||||
export const REPORT_TASK_STEPS: ReportTaskStep[] = [
|
export const REPORT_TASK_STEPS: ReportTaskStep[] = [
|
||||||
{ id: 'loadingMessages', label: '读取并筛选聊天记录' },
|
{ id: 'loadingMessages', label: '读取并筛选聊天记录' },
|
||||||
|
{ id: 'transcribingVoice', label: '转写语音消息' },
|
||||||
{ id: 'preparingInput', label: '整理日报输入' },
|
{ id: 'preparingInput', label: '整理日报输入' },
|
||||||
{ id: 'requestingModel', label: '调用模型生成内容' },
|
{ id: 'requestingModel', label: '调用模型生成内容' },
|
||||||
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
|
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
|
||||||
@@ -210,17 +220,10 @@ const applyGroupMemberNames = async (
|
|||||||
const senderId = String(message.senderId || message.name || '')
|
const senderId = String(message.senderId || message.name || '')
|
||||||
const member = memberMap.get(senderId)
|
const member = memberMap.get(senderId)
|
||||||
if (!member) return message
|
if (!member) return message
|
||||||
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
const name = resolveMemberName({ ...member, wxid: senderId }, preference)
|
||||||
// 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))
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
name: name || (preference === 'remark' ? message.name : ''),
|
name: !isInternalName(name) ? name : preference === 'remark' ? message.name : '',
|
||||||
img: message.img || member.avatar
|
img: message.img || member.avatar
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -238,6 +241,7 @@ export function useGroupReportGeneration({
|
|||||||
reportMessages: Message[]
|
reportMessages: Message[]
|
||||||
messageTypeCounts: Record<SummaryMessageType, number>
|
messageTypeCounts: Record<SummaryMessageType, number>
|
||||||
rangeState: RangeMessageState
|
rangeState: RangeMessageState
|
||||||
|
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
|
||||||
generatedImage: string | null
|
generatedImage: string | null
|
||||||
reportPaths: ReportPaths | null
|
reportPaths: ReportPaths | null
|
||||||
generationMetadata: ReportGenerationMetadata
|
generationMetadata: ReportGenerationMetadata
|
||||||
@@ -260,6 +264,8 @@ export function useGroupReportGeneration({
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [rangeMessages, setRangeMessages] = useState<Message[]>([])
|
const [rangeMessages, setRangeMessages] = useState<Message[]>([])
|
||||||
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
||||||
|
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
|
||||||
|
useState<VoiceTranscriptionProgress | null>(null)
|
||||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||||
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||||
@@ -290,6 +296,7 @@ export function useGroupReportGeneration({
|
|||||||
|
|
||||||
const isGenerating =
|
const isGenerating =
|
||||||
phase === 'loadingMessages' ||
|
phase === 'loadingMessages' ||
|
||||||
|
phase === 'transcribingVoice' ||
|
||||||
phase === 'preparingInput' ||
|
phase === 'preparingInput' ||
|
||||||
phase === 'requestingModel' ||
|
phase === 'requestingModel' ||
|
||||||
phase === 'exportingReport'
|
phase === 'exportingReport'
|
||||||
@@ -367,9 +374,28 @@ export function useGroupReportGeneration({
|
|||||||
setError('')
|
setError('')
|
||||||
setGeneratedImage(null)
|
setGeneratedImage(null)
|
||||||
setReportPaths(null)
|
setReportPaths(null)
|
||||||
|
setVoiceTranscriptionProgress(null)
|
||||||
setGenerationMetadata({ generationLogs: [] })
|
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> => {
|
const generate = useCallback(async (): Promise<void> => {
|
||||||
if (isGenerating) return
|
if (isGenerating) return
|
||||||
if (!sourceContact) {
|
if (!sourceContact) {
|
||||||
@@ -446,12 +472,16 @@ export function useGroupReportGeneration({
|
|||||||
filteredMessageCount: filteredMessages.length
|
filteredMessageCount: filteredMessages.length
|
||||||
})
|
})
|
||||||
|
|
||||||
setPhase('preparingInput')
|
setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput')
|
||||||
failedAt = '整理日报输入'
|
failedAt = '整理日报输入'
|
||||||
const input = await trackStep('整理输入', async () => {
|
const input = await trackStep('整理输入', async () => {
|
||||||
|
const messagesWithTranscripts = selectedTypes.has('语音')
|
||||||
|
? await transcribeSelectedVoiceMessages(filteredMessages)
|
||||||
|
: filteredMessages
|
||||||
|
setPhase('preparingInput')
|
||||||
const namedReportMessages = await applyGroupMemberNames(
|
const namedReportMessages = await applyGroupMemberNames(
|
||||||
sourceContact,
|
sourceContact,
|
||||||
filteredMessages,
|
messagesWithTranscripts,
|
||||||
memberNamePreference
|
memberNamePreference
|
||||||
)
|
)
|
||||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
|
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
|
||||||
@@ -610,7 +640,8 @@ export function useGroupReportGeneration({
|
|||||||
sourceContact,
|
sourceContact,
|
||||||
summaryDateRange,
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
templateId
|
templateId,
|
||||||
|
transcribeSelectedVoiceMessages
|
||||||
])
|
])
|
||||||
|
|
||||||
const clearError = useCallback((): void => {
|
const clearError = useCallback((): void => {
|
||||||
@@ -639,6 +670,7 @@ export function useGroupReportGeneration({
|
|||||||
reportMessages,
|
reportMessages,
|
||||||
messageTypeCounts,
|
messageTypeCounts,
|
||||||
rangeState,
|
rangeState,
|
||||||
|
voiceTranscriptionProgress,
|
||||||
generatedImage,
|
generatedImage,
|
||||||
reportPaths,
|
reportPaths,
|
||||||
generationMetadata,
|
generationMetadata,
|
||||||
|
|||||||
@@ -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 {
|
.export-contact-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.export-format-grid button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
@@ -996,6 +1094,29 @@
|
|||||||
font-weight: 500;
|
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 {
|
.export-progress-bar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user