mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a52da455c3 | ||
|
|
1fc39678ff | ||
|
|
900e4c504c | ||
|
|
8ca8ad9dee | ||
|
|
aed373db93 | ||
|
|
f120f2079c | ||
|
|
46c9b98d75 | ||
|
|
e3b668314d | ||
|
|
4436d7c8ce | ||
|
|
daeba3388d | ||
|
|
8fac752e2b | ||
|
|
34b86af0be | ||
|
|
d439b4b749 | ||
|
|
a80624d6ab | ||
|
|
cf3f115124 | ||
|
|
0c1d859e1b | ||
|
|
2482c23c5e | ||
|
|
0706ba13e6 | ||
|
|
55c6fb8cd3 | ||
|
|
b6901c9d0c | ||
|
|
4d95fd7650 | ||
|
|
775b5aff18 | ||
|
|
2f2f682fa0 | ||
|
|
2354fd0766 | ||
|
|
6192e7cd35 | ||
|
|
68f0c0b5a3 | ||
|
|
7a5499f093 | ||
|
|
d1a090bb6b | ||
|
|
8a0d3b02d9 |
@@ -26,3 +26,11 @@ VITE_IMAGE_AES_KEY=
|
||||
# Electron E2E test window close delay in milliseconds.
|
||||
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
|
||||
WXE_E2E_CLOSE_DELAY_MS=2000
|
||||
|
||||
# Experimental self-hosted WeChat share-card service
|
||||
# Copy these placeholders to .env. Never commit real AppSecret or UPLOAD_TOKEN values.
|
||||
WECHAT_SHARE_DOMAIN=share.example.com
|
||||
WECHAT_SHARE_APP_ID=
|
||||
WECHAT_SHARE_APP_SECRET=
|
||||
# Leave empty to let docs/skill/setup-wechat-share-card/scripts/setup.sh generate one.
|
||||
WECHAT_SHARE_UPLOAD_TOKEN=
|
||||
|
||||
@@ -12,6 +12,8 @@ test-results/
|
||||
resources/connectors/wechat/
|
||||
.omc
|
||||
.codex/
|
||||
services/share-card-worker/.wrangler/
|
||||
services/share-card-worker/wrangler.local.jsonc
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
shamefully-hoist=true
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
# Keep the Windows x64 sherpa-onnx optional runtime available when packaging
|
||||
# Windows from macOS/Linux hosts.
|
||||
supportedArchitectures.os[]=darwin
|
||||
supportedArchitectures.os[]=win32
|
||||
supportedArchitectures.cpu[]=arm64
|
||||
supportedArchitectures.cpu[]=x64
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# WechatExplorer
|
||||
# TraceMemo(迹忆)
|
||||
|
||||
<p align="center">
|
||||
<img src="./build/icon.png" width="120" alt="WechatExplorer Logo" />
|
||||
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
|
||||
</p>
|
||||
|
||||
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
||||
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 WechatExplorer</b></a>
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||
·
|
||||
@@ -26,64 +26,148 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/software-1.png" alt="WechatExplorer 主界面" />
|
||||
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/机器人.png" alt="WechatExplorer 主界面" />
|
||||
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" />
|
||||
</p>
|
||||
|
||||
## WechatExplorer 是什么
|
||||
---
|
||||
|
||||
WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台。
|
||||
## TraceMemo(迹忆)是什么
|
||||
|
||||
TraceMemo(迹忆)是一款**本地优先、可追溯的 AI 微信知识与分析工作台**。
|
||||
|
||||
TraceMemo 原名 **WechatExplorer**,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
|
||||
|
||||
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
|
||||
|
||||
你可以直接浏览聊天,也可以用自然语言提问:
|
||||
|
||||
> “上个月我们讨论过哪些发布问题?”
|
||||
>
|
||||
> “张三之前发过的项目地址在哪里?”
|
||||
>
|
||||
> “技术交流群今天有哪些结论和待办?”
|
||||
|
||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。
|
||||
|
||||
你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
||||
|
||||
TraceMemo 不提供任何微信聊天数据,也不鼓励收集、上传、出售、共享或未经授权处理他人的聊天记录。使用 TraceMemo 时,请确保你对所处理的数据具有合法的访问和使用权限,并自行承担相应的数据安全与合规责任。
|
||||
|
||||
---
|
||||
|
||||
## 为什么叫 TraceMemo(迹忆)
|
||||
|
||||
<details>
|
||||
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
|
||||
|
||||
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
|
||||
|
||||
“迹忆”可以理解为“留下痕迹的记忆”。
|
||||
|
||||
TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
|
||||
|
||||
> **品牌说明**
|
||||
>
|
||||
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 项目缘起
|
||||
|
||||
<details>
|
||||
|
||||
TraceMemo 最早叫 **WechatExplorer**。
|
||||
|
||||
**2025 年 12 月**,我做出了第一个版本。当时功能很简单:解析微信 3.0 的聊天记录,再用 AI 生成群聊日报。最初只是给自己用,想把散落在微信里的信息重新找出来,也方便看看群里每天聊了什么。
|
||||
|
||||
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
|
||||
|
||||
为了支持微信 4.x,我开始重新研究数据访问。这部分工作得到了 **WeFlow** 很大的帮助。TraceMemo 目前的微信 4.x 数据连接能力,参考并使用了 **WeFlow 历史版本中的相关实现和思路**,包括数据库密钥获取、图片解密等底层能力。
|
||||
|
||||
> **没有 WeFlow,就没有今天的 TraceMemo。**
|
||||
|
||||
WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继续做后面的事情:让聊天记录可以被搜索、理解和总结,也让 AI 给出的答案能够回到原始消息核对。
|
||||
|
||||
在此基础上,项目陆续加入了:
|
||||
|
||||
- 本地知识库
|
||||
- 消息来源追溯
|
||||
- 群聊日报
|
||||
- 语音消息也参与知识库等问答
|
||||
- 微信机器人
|
||||
- Local HTTP API
|
||||
- Reader Skill
|
||||
- Agent 接入
|
||||
- 多种聊天记录导出能力
|
||||
|
||||
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
|
||||
|
||||
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
|
||||
|
||||
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💬 交流与反馈
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流与售后群二维码" width="280" />
|
||||
<img src="./public/二维码.jpg" alt="TraceMemo 交流与售后群二维码" width="280" />
|
||||
</p>
|
||||
|
||||
|
||||
## 从你的任务开始
|
||||
|
||||
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
||||
| ----------------------------------------- | ------------------------------------------------------- | ------------------------------------ |
|
||||
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
|
||||
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
|
||||
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
|
||||
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
|
||||
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
|
||||
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
|
||||
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
|
||||
| 直接在微信里向 WechatExplorer 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
|
||||
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
|
||||
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
|
||||
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
|
||||
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
|
||||
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
|
||||
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
|
||||
| 把群聊日报生成微信分享卡片(实验性) | [微信分享卡片](./docs/deployment/experimental-wechat-share-card.md) | 自备 Cloudflare、域名和微信测试号 |
|
||||
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
|
||||
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
|
||||
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
|
||||
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
|
||||
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
|
||||
|
||||
---
|
||||
|
||||
## 最核心的三个能力
|
||||
|
||||
### 浏览和搜索微信历史
|
||||
### 生成群聊日报
|
||||
|
||||
- 浏览联系人、群聊、折叠群聊和公众号消息。
|
||||
- 查看文本、图片、视频、语音、文件、链接、引用、小程序等内容。
|
||||
- 搜索会话或当前聊天中的关键词。
|
||||
- 从 AI 结果跳回对应聊天位置。
|
||||
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。
|
||||
|
||||
详细说明:[聊天档案与普通搜索](./docs/user-guide/chat-archive.md)
|
||||
报告包含:
|
||||
|
||||
- 热点
|
||||
- 重要消息
|
||||
- 资源
|
||||
- 问答
|
||||
- 待办
|
||||
- 未解决事项
|
||||
- 活跃统计
|
||||
- 图片精选
|
||||
|
||||
具体内容取决于消息、媒体是否可读以及模型能力。
|
||||
详细说明:[生成群聊日报](./docs/user-guide/report.md)
|
||||
|
||||
</details>
|
||||
|
||||
### AI 帮你找回聊过的内容
|
||||
|
||||
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
|
||||
|
||||
WechatExplorer 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
|
||||
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。
|
||||
|
||||
你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
|
||||
@@ -93,66 +177,99 @@ WechatExplorer 会先在本机查找候选消息,再把整理后的少量来
|
||||
|
||||
### 直接在微信里问你的历史聊天
|
||||
|
||||
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。例如,你可以直接给机器人发送“最近 5 个会话”“张三最近和我聊了什么”,或者让它生成指定群聊的总结图片。WechatExplorer 会在本机读取已连接的聊天数据并把结果回复到微信。
|
||||
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。
|
||||
|
||||
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
|
||||
例如,你可以直接给机器人发送:
|
||||
|
||||
详细步骤和能力边界见[在微信里向 WechatExplorer 提问](./docs/agent/agent-hub.md)。
|
||||
- “最近 5 个会话”
|
||||
- “张三最近和我聊了什么”
|
||||
- “总结今天的技术交流群”
|
||||
|
||||
TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
|
||||
|
||||
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。
|
||||
|
||||
当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
|
||||
|
||||
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
|
||||
|
||||
---
|
||||
|
||||
## 其他能力
|
||||
|
||||
### 本地知识库
|
||||
|
||||
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
|
||||
<details>
|
||||
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。
|
||||
|
||||
它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
|
||||
|
||||
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
|
||||
|
||||
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
|
||||
|
||||
### 生成群聊日报
|
||||
</details>
|
||||
|
||||
### 实验性:生成微信分享卡片
|
||||
|
||||
<details>
|
||||
<summary>查看群聊日报示例、内容和导出方式</summary>
|
||||
TraceMemo 可以把群聊日报长图上传到你自己部署的 Cloudflare Worker 和 R2,并生成可在微信中分享的临时网页、二维码及卡片信息。
|
||||
|
||||
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。报告可能包含热点、重要消息、资源、问答、待办、未解决事项、活跃统计和图片精选;具体内容取决于消息、媒体是否可读以及模型能力。
|
||||
该功能需要自备 Cloudflare 账号、域名和微信测试号,目前不属于开箱即用的稳定功能。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/report-template-1.png" alt="群聊日报示例" />
|
||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享效果示例" />
|
||||
</p>
|
||||
|
||||
详细说明:[生成群聊日报](./docs/user-guide/report.md)
|
||||
详细说明:[实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)。
|
||||
|
||||
不熟悉命令行的用户,可以把[自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)直接交给 Codex 或 Claude Code。
|
||||
|
||||
</details>
|
||||
|
||||
### 转写微信语音
|
||||
|
||||
WechatExplorer 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
|
||||
<details>
|
||||
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。
|
||||
|
||||
转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
|
||||
|
||||
详细说明:[语音转文字](./docs/user-guide/voice.md)
|
||||
|
||||
### 防撤回
|
||||
|
||||
可选开启后,WechatExplorer 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
|
||||
|
||||
详细说明:[防撤回](./docs/user-guide/recall-protection.md)
|
||||
</details>
|
||||
|
||||
### 导出长期可用的聊天档案
|
||||
|
||||
支持 HTML、CSV、JSON 和 Markdown。HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
|
||||
<details>
|
||||
支持 HTML、CSV、JSON 和 Markdown。
|
||||
|
||||
HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
|
||||
|
||||
详细说明:[导出聊天](./docs/user-guide/export.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 在外部 Agent 中查询微信历史
|
||||
|
||||
通过 Reader Skill 和本机 Local HTTP API,Codex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。这和微信机器人是两条不同路径:微信机器人收到消息后在微信中回复;外部 Agent 则主动查询历史。
|
||||
<details>
|
||||
通过 Reader Skill 和本机 Local HTTP API,Codex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。
|
||||
|
||||
这和微信机器人是两条不同路径:
|
||||
|
||||
- **微信机器人**:收到消息后在微信中回复。
|
||||
- **外部 Agent**:主动查询历史。
|
||||
|
||||
安装和技术说明请看[Agent 接入概览](./docs/agent/overview.md)与[Local HTTP API](./docs/agent/api.md)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 它如何工作
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[本机微信数据] --> B[WechatExplorer 读取与解析]
|
||||
A[本机微信数据] --> B[TraceMemo 读取与解析]
|
||||
B --> C[聊天档案]
|
||||
B --> D[本地知识库与搜索]
|
||||
D --> E[筛选相关聊天来源]
|
||||
@@ -175,39 +292,57 @@ flowchart LR
|
||||
|
||||
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
|
||||
|
||||
---
|
||||
|
||||
## 支持平台与安装包
|
||||
|
||||
| 平台 | 处理器架构 | Releases 安装包 |
|
||||
| ------- | ----------------------------- | --------------- |
|
||||
| Windows | x64 | `-setup.exe` |
|
||||
| 平台 | 处理器架构 | Releases 安装包 |
|
||||
| ------- | ------------------------------ | --------------- |
|
||||
| Windows | x64 | `-setup.exe` |
|
||||
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
|
||||
|
||||
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
||||
当前版本不支持 Intel 芯片的 Mac。
|
||||
|
||||
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
|
||||
2. 启动 WechatExplorer,按照“第一次使用”页面选择微信数据目录。
|
||||
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
||||
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
|
||||
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
||||
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
|
||||
|
||||
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
|
||||
|
||||
当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
|
||||
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。
|
||||
|
||||
迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
|
||||
|
||||
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
|
||||
完整步骤:[第一次使用 WechatExplorer](./docs/user-guide/getting-started.md)
|
||||
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
|
||||
|
||||
---
|
||||
|
||||
## 配置 AI
|
||||
|
||||
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
|
||||
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。
|
||||
|
||||
应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
|
||||
|
||||
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
|
||||
|
||||
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
- [文档首页](./docs/README.md)
|
||||
@@ -216,6 +351,8 @@ Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行
|
||||
- [AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
||||
- [本地知识库](./docs/user-guide/knowledge.md)
|
||||
- [群聊日报](./docs/user-guide/report.md)
|
||||
- [实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)
|
||||
- [微信分享卡片自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)
|
||||
- [语音转文字](./docs/user-guide/voice.md)
|
||||
- [导出聊天](./docs/user-guide/export.md)
|
||||
- [防撤回](./docs/user-guide/recall-protection.md)
|
||||
@@ -225,6 +362,8 @@ Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行
|
||||
- [Local HTTP API](./docs/agent/api.md)
|
||||
- [开发与测试](./docs/development/overview.md)
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
|
||||
@@ -246,43 +385,62 @@ pnpm test:e2e:build
|
||||
|
||||
完整说明:[开发、测试与构建](./docs/development/overview.md)
|
||||
|
||||
---
|
||||
|
||||
## 支持与反馈
|
||||
|
||||
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。提交 Issue 时请提供操作系统、微信版本、WechatExplorer 版本、复现步骤和已遮挡敏感信息的截图。
|
||||
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
|
||||
|
||||
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
|
||||
提交 Issue 时请提供:
|
||||
|
||||
- 操作系统
|
||||
- 微信版本
|
||||
- TraceMemo 版本
|
||||
- 复现步骤
|
||||
- 已遮挡敏感信息的截图
|
||||
|
||||
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
|
||||
|
||||
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
|
||||
|
||||
---
|
||||
|
||||
## 许可说明
|
||||
|
||||
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `LICENSE` 文件;贡献、复制或再分发前,请先向维护者确认 WechatExplorer 本身的许可范围。
|
||||
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件。
|
||||
|
||||
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
|
||||
|
||||
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
|
||||
|
||||
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
<details>
|
||||
<summary>展开致谢与参考项目</summary>
|
||||
TraceMemo 的诞生离不开开源社区中许多优秀项目的工作。
|
||||
|
||||
WechatExplorer 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
|
||||
### 特别感谢 WeFlow
|
||||
|
||||
特别感谢:
|
||||
TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库密钥获取、图片解密等底层能力。
|
||||
|
||||
特别感谢作者 **hicccc77** 的理解和包容。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
|
||||
|
||||
### 其他参考项目
|
||||
|
||||
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
||||
- 提供了微信数据库解析相关思路。
|
||||
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
|
||||
- 参考了数据库密钥获取、图片解密等实现思路。
|
||||
- 提供了数据库解析相关思路。
|
||||
|
||||
- **[chatlog](https://github.com/sjzar/chatlog)**
|
||||
- 提供了聊天记录导出与数据处理方面的参考。
|
||||
- 提供了数据处理方面的参考。
|
||||
|
||||
在此基础上,WechatExplorer 进行了重新设计与实现,包括:
|
||||
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
|
||||
|
||||
- AI 问问微信
|
||||
- AI 群聊日报
|
||||
- 本地 HTTP API
|
||||
- Reader Skill
|
||||
- Agent Hub
|
||||
- 新手引导
|
||||
- Electron + React 全新界面
|
||||
- 本地优先 AI 工作流
|
||||
---
|
||||
|
||||
感谢所有开源作者。
|
||||
|
||||
</details>
|
||||
<p align="center">
|
||||
<b>TraceMemo(迹忆)</b>
|
||||
<br />
|
||||
把微信聊过的事,找回来、问清楚、留下来。
|
||||
</p>
|
||||
|
||||
@@ -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 承担细节;
|
||||
- 明确区分当前能力、限制、待验证事项和未来场景;
|
||||
- 不因文案完整性补充不存在的能力;
|
||||
- 不把测试通过等同于真实用户环境已经得到验证。
|
||||
+9
-5
@@ -1,6 +1,6 @@
|
||||
# WechatExplorer 文档
|
||||
# TraceMemo 文档
|
||||
|
||||
WechatExplorer 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
||||
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
@@ -12,10 +12,12 @@ WechatExplorer 的文档按“你想完成什么”组织,而不是按源码
|
||||
|
||||
- [建立本地知识库](./user-guide/knowledge.md)
|
||||
- [生成群聊日报和总结](./user-guide/report.md)
|
||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
|
||||
- [语音转文字](./user-guide/voice.md)
|
||||
- [导出聊天档案](./user-guide/export.md)
|
||||
- [防撤回](./user-guide/recall-protection.md)
|
||||
- [在微信里向 WechatExplorer 提问](./agent/agent-hub.md)
|
||||
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
|
||||
- [数据、隐私与安全](./user-guide/privacy.md)
|
||||
- [常见问题与排查](./user-guide/troubleshooting.md)
|
||||
|
||||
@@ -26,7 +28,7 @@ WechatExplorer 的文档按“你想完成什么”组织,而不是按源码
|
||||
|
||||
## 微信机器人和外部 Agent
|
||||
|
||||
WechatExplorer 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
|
||||
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
|
||||
|
||||
| 你想做什么 | 应该看哪里 |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
@@ -55,6 +57,8 @@ WechatExplorer 有两种不同的接入方式。微信机器人是普通用户
|
||||
|
||||
- [macOS 数据访问说明](./platform/macos.md)
|
||||
- [开发、测试与构建](./development/overview.md)
|
||||
- [本地启动排障](./development/local-startup-troubleshooting.md)
|
||||
- [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md)
|
||||
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
|
||||
|
||||
当前工作区版本:**2.1.9**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 在微信里向 WechatExplorer 提问(Agent Hub)
|
||||
# 在微信里向 TraceMemo 提问(Agent Hub)
|
||||
|
||||
Agent Hub 是 WechatExplorer 内置的微信机器人入口,也是应用一级导航中的“Agent”页面。你先扫码登录一个微信机器人账号,再用微信账号向机器人发送文字;本机 Agent Hub 会接收消息、读取已经连接的微信数据,必要时调用已配置的 AI,再把结果回复给发送者。
|
||||
Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导航中的“Agent”页面。你先扫码登录一个微信机器人账号,再用微信账号向机器人发送文字;本机 Agent Hub 会接收消息、读取已经连接的微信数据,必要时调用已配置的 AI,再把结果回复给发送者。
|
||||
|
||||
普通用户不需要安装 Reader Skill,也不需要配置 API Token。先连接微信数据库,再扫码登录机器人即可开始;需要总结或自然语言理解的任务还要配置 AI Provider。
|
||||
|
||||
@@ -11,7 +11,7 @@ Agent Hub 是 WechatExplorer 内置的微信机器人入口,也是应用一级
|
||||
|
||||
## 连接器和 Agent Hub 是什么关系
|
||||
|
||||
你不需要单独部署这些组件。扫码后,后台的微信连接器负责登录机器人、保持连接、接收微信消息和发送回复;Agent Hub 负责判断消息要做什么、查询 WechatExplorer 本地数据、调用 AI 并组织结果。可以把它理解为:连接器负责“和微信通信”,Hub 负责“处理任务”。
|
||||
你不需要单独部署这些组件。扫码后,后台的微信连接器负责登录机器人、保持连接、接收微信消息和发送回复;Agent Hub 负责判断消息要做什么、查询 TraceMemo 本地数据、调用 AI 并组织结果。可以把它理解为:连接器负责“和微信通信”,Hub 负责“处理任务”。
|
||||
|
||||
## 你能做什么
|
||||
|
||||
@@ -50,9 +50,9 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
|
||||
|
||||
## 需要满足的条件
|
||||
|
||||
- WechatExplorer 的微信数据库已经连接,并且数据 API 可以查询;
|
||||
- TraceMemo 的微信数据库已经连接,并且数据 API 可以查询;
|
||||
- 依赖总结或自然语言理解的任务,需要在“设置 → AI 模型”配置可用的 AI 服务;
|
||||
- WechatExplorer 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
|
||||
- TraceMemo 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
|
||||
|
||||
## 安全与边界
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
## 当前安全边界
|
||||
|
||||
WechatExplorer 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
|
||||
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
|
||||
|
||||
## Bearer Token
|
||||
|
||||
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
|
||||
|
||||
- `/api/v1/health` 是公开健康检查;
|
||||
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
||||
- Token 由应用生成,使用 32 个随机字节编码;
|
||||
@@ -17,7 +19,7 @@ WechatExplorer 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台
|
||||
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
||||
|
||||
```bash
|
||||
export WECHATEXPLORER_API_TOKEN="<TOKEN>"
|
||||
export TRACEMEMO_API_TOKEN="<TOKEN>"
|
||||
```
|
||||
|
||||
## CORS 与 Origin
|
||||
|
||||
+21
-19
@@ -1,4 +1,4 @@
|
||||
# WechatExplorer Local HTTP API
|
||||
# TraceMemo Local HTTP API
|
||||
|
||||
本文面向需要自己写集成的开发者。普通用户请先阅读[Agent 接入概览](./overview.md)。
|
||||
|
||||
@@ -17,29 +17,31 @@
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 读取数据
|
||||
export WECHATEXPLORER_API_TOKEN="<从 API Center 复制的 Token>"
|
||||
curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
|
||||
export TRACEMEMO_API_TOKEN="<从 API Center 复制的 Token>"
|
||||
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||
```
|
||||
|
||||
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
||||
|
||||
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
|
||||
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
||||
|
||||
### 这些端点与实时机器人有什么关系
|
||||
|
||||
@@ -57,7 +59,7 @@ curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
|
||||
- `YYYY-MM-DD/HH:mm`:从该分钟开始的 60 秒;
|
||||
- 也可以使用 Unix 秒级 `startTime` 和 `endTime`。
|
||||
|
||||
时间按运行 WechatExplorer 的本机时区解析。用户说“今天”“昨天”时,先调用 `current_time`,再根据返回的 `localDate` 计算日期,避免使用 Agent 自己的时区。
|
||||
时间按运行 TraceMemo 的本机时区解析。用户说“今天”“昨天”时,先调用 `current_time`,再根据返回的 `localDate` 计算日期,避免使用 Agent 自己的时区。
|
||||
|
||||
## 常用工作流
|
||||
|
||||
@@ -65,7 +67,7 @@ curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
|
||||
|
||||
```bash
|
||||
BASE="http://127.0.0.1:6131/api/v1"
|
||||
AUTH="Authorization: Bearer $WECHATEXPLORER_API_TOKEN"
|
||||
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
|
||||
|
||||
curl -H "$AUTH" "$BASE/resolve?q=技术交流群"
|
||||
curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 在微信机器人或外部 Agent 中使用 WechatExplorer
|
||||
# 在微信机器人或外部 Agent 中使用 TraceMemo
|
||||
|
||||
WechatExplorer 提供两条不同路径。先按你实际想做的事选择,不需要先理解 Agent、Skill 或 API 等术语。
|
||||
TraceMemo 提供两条不同路径。先按你实际想做的事选择,不需要先理解 Agent、Skill 或 API 等术语。
|
||||
|
||||
| 你想做什么 | 使用方式 | 需要什么 |
|
||||
| ---------------------------------------------------- | ----------------------------- | --------------------------------------------------------- |
|
||||
@@ -9,7 +9,7 @@ WechatExplorer 提供两条不同路径。先按你实际想做的事选择,
|
||||
|
||||
## 直接在微信里提问
|
||||
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”,扫码登录一个微信机器人账号。之后用另一个微信账号向机器人发送文字,它会调用 WechatExplorer 的本机数据,必要时使用已配置的 AI,再把结果回复给发送者。
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”,扫码登录一个微信机器人账号。之后用另一个微信账号向机器人发送文字,它会调用 TraceMemo 的本机数据,必要时使用已配置的 AI,再把结果回复给发送者。
|
||||
|
||||
可以先尝试:
|
||||
|
||||
@@ -23,7 +23,7 @@ WechatExplorer 提供两条不同路径。先按你实际想做的事选择,
|
||||
|
||||
## 在外部 Agent 中查询历史微信
|
||||
|
||||
Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以通过 WechatExplorer Local HTTP API 按需读取联系人、群聊、最近会话、指定时间范围的聊天和群成员信息。
|
||||
Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以通过 TraceMemo Local HTTP API 按需读取联系人、群聊、最近会话、指定时间范围的聊天和群成员信息。
|
||||
|
||||
典型问题包括:
|
||||
|
||||
@@ -35,11 +35,11 @@ Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Cod
|
||||
|
||||
## 外部 Agent 的安装步骤
|
||||
|
||||
1. 启动 WechatExplorer 并完成微信数据库连接。
|
||||
1. 启动 TraceMemo 并完成微信数据库连接。
|
||||
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
|
||||
3. 选择目标 Agent,点击“复制安装指令”。
|
||||
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
|
||||
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `WECHATEXPLORER_API_TOKEN`。
|
||||
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `TRACEMEMO_API_TOKEN`。
|
||||
6. 先让 Agent 调用 health,再尝试查询最近会话。
|
||||
|
||||
详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。
|
||||
|
||||
@@ -2,27 +2,29 @@
|
||||
|
||||
## 先理解它能做什么
|
||||
|
||||
Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以按需调用 WechatExplorer,读取联系人、群聊、最近会话、指定时间的聊天和群成员信息。
|
||||
Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以按需调用 TraceMemo,读取联系人、群聊、最近会话、指定时间的聊天和群成员信息。
|
||||
|
||||
它使用的是 WechatExplorer Local HTTP API,不是 MCP Server。
|
||||
它使用的是 TraceMemo Local HTTP API,不是 MCP Server。
|
||||
|
||||
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
|
||||
|
||||
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
|
||||
|
||||
## 推荐安装流程
|
||||
|
||||
1. 启动 WechatExplorer 并完成数据库连接。
|
||||
1. 启动 TraceMemo 并完成数据库连接。
|
||||
2. 打开“API Center”,确认 API 服务和数据库状态正常。
|
||||
3. 在 Reader Skill 区域选择目标 Agent,点击“复制安装指令”。
|
||||
4. 把指令粘贴到对应 Agent 的 Skill/配置目录;应用会根据本机路径生成适合 Codex、Claude Code、OpenClaw 或通用 Agent 的说明。
|
||||
5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置:
|
||||
|
||||
```bash
|
||||
export WECHATEXPLORER_API_TOKEN="<YOUR_API_TOKEN>"
|
||||
export TRACEMEMO_API_TOKEN="<YOUR_API_TOKEN>"
|
||||
```
|
||||
|
||||
6. 先执行 health 检查,再读取数据端点。
|
||||
|
||||
WechatExplorer 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
|
||||
TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
|
||||
|
||||
## Agent 的读取顺序
|
||||
|
||||
@@ -39,7 +41,7 @@ WechatExplorer 不会自动把 Token 写进 Agent 配置。重新生成 Token
|
||||
```bash
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
curl -H "Authorization: Bearer $WECHATEXPLORER_API_TOKEN" \
|
||||
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||
```
|
||||
|
||||
|
||||
@@ -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 鉴权。这是一次有意的兼容性变化:除健康检查外,数据接口不再接受裸请求。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- 2.1.9 中,相同请求必须携带 `Authorization: Bearer <TOKEN>`,否则返回 `401`;
|
||||
- `GET /api/v1/health` 保持公开;
|
||||
- 升级后应用会生成并安全保存 Token,原有 API 启用状态、监听地址和端口设置保持不变;
|
||||
- Token 可在 WechatExplorer → API Center 中显示、复制和重新生成;
|
||||
- Token 可在 TraceMemo → API Center 中显示、复制和重新生成;
|
||||
- Reader Skill、Codex、Claude Code、OpenClaw 和其他本地 Agent 需要在自己的环境中设置 `WECHATEXPLORER_API_TOKEN`。
|
||||
|
||||
如果旧 Agent 无法访问,请先从 API Center 复制当前 Token,再确认每个非 health 请求都带有 Bearer header。完整规则见[API 安全](./api-security.md)。
|
||||
|
||||
@@ -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 回答后,你可以继续查看它参考了哪些聊天内容、这些内容来自哪个会话和时间,并跳回原始消息检查上下文。
|
||||
|
||||
这让 WechatExplorer 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
|
||||
这让 TraceMemo 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
|
||||
|
||||
## 三类来源信息
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# WechatExplorer 如何把聊天变成可用的信息
|
||||
# TraceMemo 如何把聊天变成可用的信息
|
||||
|
||||
你可以把一次任务想成下面这条路径:
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
# 实验性功能:自托管微信分享卡片
|
||||
|
||||

|
||||
|
||||
> **实验性功能**
|
||||
> 该能力需要用户自行准备 Cloudflare、域名和微信公众平台测试号,目前不属于开箱即用的稳定功能。Cloudflare、微信 JS-SDK、测试号权限或微信客户端行为变化,都可能导致分享卡片失效。
|
||||
|
||||
## 新手推荐:直接交给 Agent
|
||||
|
||||
如果你不熟悉 Cloudflare、Wrangler 或命令行,不需要手动照着整篇文档操作。把下面这个 Skill 文件夹交给 Codex、Claude Code 或其他能够操作项目终端的编程 Agent:
|
||||
|
||||
```text
|
||||
docs/skill/setup-wechat-share-card/
|
||||
```
|
||||
|
||||
然后对 Agent 说:
|
||||
|
||||
```text
|
||||
请使用 setup-wechat-share-card Skill,帮我部署 TraceMemo 的实验性微信分享卡片服务。尽量自动完成,只在缺少必要信息时一次性问我。
|
||||
```
|
||||
|
||||
Agent 会自动:
|
||||
|
||||
- 检查 Node.js、pnpm 和 Wrangler;
|
||||
- 必要时临时下载 Wrangler;
|
||||
- 打开 Cloudflare 登录并执行 `whoami`;
|
||||
- 自动生成 `UPLOAD_TOKEN`;
|
||||
- 创建或复用私有 R2 Bucket;
|
||||
- 写入 Worker Secret;
|
||||
- 根据你的域名生成本机 Worker 配置;
|
||||
- 部署 Worker并执行健康检查和微信签名检查;
|
||||
- 把上传密钥复制到剪贴板,供你粘贴到 TraceMemo。
|
||||
|
||||
Agent 无法替你创建微信测试号或决定使用哪个域名,因此通常只需要你提供:
|
||||
|
||||
1. 你准备使用的分享域名,例如 `share.example.com`;
|
||||
2. 微信测试号页面中的 AppID;
|
||||
3. 微信测试号页面中的 AppSecret;
|
||||
4. 浏览器弹出 Cloudflare OAuth 页面时完成一次登录授权。
|
||||
|
||||
真实配置保存在被 Git 忽略的本机 `.env` 中,不会写入 `.env.example`。不要把 `.env` 发给别人或提交到仓库。
|
||||
|
||||
TraceMemo 可以把已经生成的群聊日报长图发布为一个临时网页,并在微信中分享成带有标题、描述和缩略图的卡片。
|
||||
|
||||
TraceMemo **不提供公共卡片服务器**。使用该功能前,需要按照本文部署一套属于你自己的卡片服务。日报图片将上传到你自己的 Cloudflare R2,而不是上传到 TraceMemo 作者的服务器。
|
||||
|
||||
## 这个功能解决什么问题
|
||||
|
||||
直接把日报 PNG 发到微信,只会显示为一张普通图片。微信卡片还需要:
|
||||
|
||||
- 一个域名 (未能备案的话 在微信里点击多次 可能会被微信内置窗口提示需要备案);
|
||||
- 卡片标题和描述;
|
||||
- 一张微信可以读取的缩略图;
|
||||
- 微信 JS-SDK 签名;
|
||||
- 一个临时保存日报图片的位置。
|
||||
|
||||
本项目提供的 Cloudflare Worker 负责这些工作。桌面端上传日报后,会得到一个分享链接和二维码。用微信扫码打开链接,再点击右上角菜单分享,即可生成微信卡片。
|
||||
|
||||
## 数据会经过哪里
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[TraceMemo 本机日报 PNG] -->|带 UPLOAD_TOKEN 上传| B[你的 Cloudflare Worker]
|
||||
B --> C[你的私有 R2 Bucket]
|
||||
B -->|AppID + AppSecret| D[微信公众平台接口]
|
||||
D -->|access_token 与 jsapi_ticket| B
|
||||
B --> E[临时分享网页]
|
||||
E --> F[微信 JS-SDK]
|
||||
F --> G[微信好友或群聊卡片]
|
||||
```
|
||||
|
||||
与 TraceMemo 的本地浏览能力不同,启用分享卡片后,当前日报长图、缩略图、卡片标题和描述会离开本机,上传到你控制的 Cloudflare 账号。
|
||||
|
||||
## 你需要准备什么
|
||||
|
||||
| 项目 | 用途 | 从哪里获得 |
|
||||
| ------------------------ | -------------------------------------------- | ---------------------------------------- |
|
||||
| Cloudflare 账号 | 运行 Worker 和保存 R2 图片 | 自行注册 Cloudflare |
|
||||
| 托管在 Cloudflare 的域名 | 提供 HTTPS 分享地址 | 使用自己的域名,例如 `share.example.com` |
|
||||
| R2 Bucket | 临时保存日报和缩略图 | 使用 Wrangler 创建 |
|
||||
| `UPLOAD_TOKEN` | 阻止陌生人调用你的上传接口 | **由你自己随机生成** |
|
||||
| 微信测试号 AppID | 标识调用 JS-SDK 的微信应用 | 微信公众平台接口测试号页面 |
|
||||
| 微信测试号 AppSecret | Worker 获取微信接口凭据 | 微信公众平台接口测试号页面 |
|
||||
| JS 接口安全域名 | 告诉微信哪些网页可以使用该 AppID 调用 JS-SDK | 在微信测试号页面填写你的分享域名 |
|
||||
|
||||
微信公众平台接口测试号入口:
|
||||
|
||||
<https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index>
|
||||
|
||||
微信 JS-SDK 官方文档:
|
||||
|
||||
<https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html>
|
||||
|
||||
## 理解三个重要配置
|
||||
|
||||
### `UPLOAD_TOKEN` 从哪里来
|
||||
|
||||
`UPLOAD_TOKEN` **不是从 Cloudflare 或微信后台领取的**,它是卡片服务部署者自己生成的一段随机密码。
|
||||
|
||||
它用于保护 Worker 的上传接口:TraceMemo 上传日报时,会发送:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <UPLOAD_TOKEN>
|
||||
```
|
||||
|
||||
Worker 只有在密钥完全一致时才接受上传。没有它,任何知道接口地址的人都可能向你的 R2 上传文件并消耗资源。
|
||||
|
||||
在 macOS 或 Linux 中生成一枚 64 位十六进制随机密钥:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
示例输出只用于说明格式,不要直接使用:
|
||||
|
||||
```text
|
||||
8a4d...一共 64 个十六进制字符...72ef
|
||||
```
|
||||
|
||||
生成后,同一个值需要配置到两个地方:
|
||||
|
||||
1. Cloudflare Worker Secret `UPLOAD_TOKEN`;
|
||||
2. TraceMemo“生成微信卡片”弹窗中的“上传密钥”。
|
||||
|
||||
如果两边不一致,卡片服务会返回 HTTP 401 或“未授权”。
|
||||
|
||||
TraceMemo 会使用 Electron `safeStorage` 将服务地址和上传密钥加密保存在本机。不要把密钥提交到 Git,也不要写入 `wrangler.jsonc`。
|
||||
|
||||
### AppID 和 AppSecret 从哪里来
|
||||
|
||||
打开[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index),使用微信扫码登录。
|
||||
|
||||
页面上方会显示:
|
||||
|
||||
- `appID`;
|
||||
- `appsecret`。
|
||||
|
||||
将它们分别保存为 Worker Secret:
|
||||
|
||||
```text
|
||||
WECHAT_APP_ID
|
||||
WECHAT_APP_SECRET
|
||||
```
|
||||
|
||||
它们的作用不同:
|
||||
|
||||
- AppID 用于标识这个微信测试应用;
|
||||
- AppSecret 是高敏感凭据,Worker 用它向微信服务器获取 `access_token`;
|
||||
- Worker 再使用 `access_token` 获取 `jsapi_ticket`;
|
||||
- 最后使用 `jsapi_ticket`、当前网页 URL、时间戳和随机串生成 JS-SDK 签名。
|
||||
|
||||
AppSecret 只能保存在 Worker Secret 中。不要把它填写到 TraceMemo 的“上传密钥”输入框,不要发送给前端,也不要提交到仓库。怀疑泄露时,应立即在微信后台重置并更新 Worker Secret。
|
||||
|
||||
### JS 接口安全域名是干什么的
|
||||
|
||||
JS 接口安全域名是微信对网页来源的白名单。
|
||||
|
||||
假设你的分享服务地址是:
|
||||
|
||||
```text
|
||||
https://share.example.com
|
||||
```
|
||||
|
||||
那么测试号页面中的“JS 接口安全域名”应填写:
|
||||
|
||||
```text
|
||||
share.example.com
|
||||
```
|
||||
|
||||
填写时:
|
||||
|
||||
- 不带 `https://`;
|
||||
- 不带 `/s/xxx` 等路径;
|
||||
- 不要填写 Cloudflare Worker 名称;
|
||||
- 必须与用户实际打开分享页时的域名一致。
|
||||
|
||||
它不是用来解析 DNS 的。域名仍然需要先在 Cloudflare 中正确绑定到 Worker。安全域名的作用是告诉微信:允许这个域名下的网页使用当前 AppID 请求 JS-SDK 能力。
|
||||
|
||||
如果没有配置、填错域名,或签名 URL 与实际页面 URL 不一致,通常会出现 `invalid signature`、`config:fail` 或分享信息没有生效。
|
||||
|
||||
微信可能要求下载一个 TXT 验证文件,并确保它可以通过下面的地址访问:
|
||||
|
||||
```text
|
||||
https://share.example.com/微信提供的文件名.txt
|
||||
```
|
||||
|
||||
项目 Worker 已包含根路径验证文件的实现方式。你需要把自己的文件名和内容加入 `services/share-card-worker/src/index.js` 中的 `WECHAT_DOMAIN_VERIFICATION`,然后重新部署。
|
||||
|
||||
## 自托管部署步骤
|
||||
|
||||
以下命令均在项目根目录执行。
|
||||
|
||||
### 1. 登录 Cloudflare
|
||||
|
||||
项目建议使用本地 Wrangler:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler login
|
||||
pnpm exec wrangler whoami
|
||||
```
|
||||
|
||||
如果本地版本的 OAuth 登录出现 `invalid_scope` 等问题,可临时使用更新版本:
|
||||
|
||||
```bash
|
||||
pnpm dlx wrangler@latest login
|
||||
pnpm dlx wrangler@latest whoami
|
||||
```
|
||||
|
||||
登录注意事项:
|
||||
|
||||
- 让 Wrangler 自动打开浏览器最稳妥;
|
||||
- 不要复用以前生成的 OAuth 链接;
|
||||
- 不要修改链接中的 `state`、`code_challenge` 或回调地址;
|
||||
- 不建议使用无痕窗口或跨浏览器复制链接;
|
||||
- 默认回调使用 `localhost:8976`,端口被占用时先结束旧的 Wrangler 登录进程;
|
||||
- 浏览器提示授权成功后,仍应通过 `whoami` 核对账号。
|
||||
|
||||
### 2. 修改 Worker 配置
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
至少修改下面两个位置:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "share.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://share.example.com",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`routes[].pattern` 是 Worker 自定义域名,`PUBLIC_ORIGIN` 是生成分享链接和校验签名来源时使用的完整 HTTPS 地址,两者必须一致。
|
||||
|
||||
不要直接照抄仓库维护者的域名。请替换为你自己 Cloudflare 账号中的域名或子域名。
|
||||
|
||||
### 3. 创建私有 R2 Bucket
|
||||
|
||||
默认配置使用 Bucket 名称:
|
||||
|
||||
```text
|
||||
wechatexplorer-share-reports
|
||||
```
|
||||
|
||||
创建:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler r2 bucket create wechatexplorer-share-reports \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Worker 中的绑定名称是 `REPORTS`。R2 会保存:
|
||||
|
||||
```text
|
||||
cards/<card-id>/card.json
|
||||
cards/<card-id>/report.png
|
||||
cards/<card-id>/thumbnail.jpg
|
||||
```
|
||||
|
||||
- `card.json`:标题、描述、创建时间和过期时间;
|
||||
- `report.png`:完整日报长图;
|
||||
- `thumbnail.jpg`:微信卡片缩略图。
|
||||
|
||||
请保持 R2 Bucket 私有,不要启用公开 `r2.dev` 开发 URL。图片应统一通过 Worker 的随机卡片 URL 读取。
|
||||
|
||||
### 4. 生成并配置 `UPLOAD_TOKEN`
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
复制生成结果,然后执行:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler secret put UPLOAD_TOKEN \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Wrangler 提示输入时粘贴密钥。终端不会正常显示 Secret 内容。
|
||||
|
||||
### 5. 配置微信 AppID 和 AppSecret
|
||||
|
||||
从[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)复制 AppID:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler secret put WECHAT_APP_ID \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
再复制 AppSecret:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler secret put WECHAT_APP_SECRET \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Secret 不会出现在 `wrangler.jsonc` 中。如果你更换 Cloudflare 账号或重新创建 Worker,需要重新配置全部三个 Secret。
|
||||
|
||||
### 6. 配置微信测试号
|
||||
|
||||
在测试号页面完成:
|
||||
|
||||
1. 使用测试微信关注该测试号;
|
||||
2. 将 `share.example.com` 填入“JS 接口安全域名”;
|
||||
3. 按页面提示完成 TXT 文件域名验证;
|
||||
4. 确认 AppID/AppSecret 与刚才写入 Worker 的值来自同一个测试号。
|
||||
|
||||
测试号只适合开发和验证。正式公众号的接口权限、认证要求和后台菜单可能不同,请以微信公众平台实际规则为准。
|
||||
|
||||
### 7. 部署 Worker
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler deploy \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Cloudflare Custom Domain 要求域名已经位于同一 Cloudflare 账号中。如果该子域名已经存在 A、AAAA 或 CNAME 记录,绑定可能失败。删除冲突记录,或者换一个未使用的子域名,例如 `share2.example.com`。
|
||||
|
||||
更新 Secret 后,如果线上仍提示旧配置,可再执行一次完整部署。
|
||||
|
||||
## 在 TraceMemo 中配置
|
||||
|
||||
生成一份日报后,点击“生成微信卡片(实验性)”。首次使用需要填写:
|
||||
|
||||
```text
|
||||
服务地址:https://share.example.com
|
||||
上传密钥:你自己通过 openssl rand -hex 32 生成的 UPLOAD_TOKEN
|
||||
```
|
||||
|
||||
这里的“上传密钥”绝对不是微信 AppSecret。
|
||||
|
||||
配置保存后,TraceMemo 会上传当前日报和缩略图,返回二维码。使用已经关注测试号的微信扫码,打开页面后再通过右上角菜单分享。
|
||||
|
||||
## 验证部署
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl -fsS https://share.example.com/health
|
||||
```
|
||||
|
||||
正常结果类似:
|
||||
|
||||
```json
|
||||
{ "ok": true, "service": "wechatexplorer-share-card", "storage": "ready" }
|
||||
```
|
||||
|
||||
### JS-SDK 签名检查
|
||||
|
||||
```bash
|
||||
curl -fsS \
|
||||
'https://share.example.com/api/wx-signature?url=https%3A%2F%2Fshare.example.com%2Fhealth'
|
||||
```
|
||||
|
||||
正常结果应包含:
|
||||
|
||||
```text
|
||||
appId
|
||||
timestamp
|
||||
nonceStr
|
||||
signature
|
||||
```
|
||||
|
||||
响应中不应包含 AppSecret、`access_token` 或 `jsapi_ticket`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### HTTP 401 / 未授权
|
||||
|
||||
TraceMemo 中保存的上传密钥与 Worker 的 `UPLOAD_TOKEN` 不一致。重新生成或重新配置时,必须同步更新两边。
|
||||
|
||||
### “微信 JS-SDK 尚未配置”
|
||||
|
||||
Worker 缺少 `WECHAT_APP_ID` 或 `WECHAT_APP_SECRET`。执行两个 `secret put`,再重新部署。
|
||||
|
||||
### `invalid signature` 或分享信息不生效
|
||||
|
||||
依次检查:
|
||||
|
||||
- `PUBLIC_ORIGIN` 是否与浏览器实际访问的 origin 完全一致;
|
||||
- JS 接口安全域名是否只填写了域名;
|
||||
- AppID/AppSecret 是否属于同一个测试号;
|
||||
- AppSecret 是否已被重置但 Worker 仍保存旧值;
|
||||
- 分享页面是否经过了改变 URL 的代理或重定向;
|
||||
- 测试微信是否已关注测试号。
|
||||
|
||||
### 自定义域名绑定失败
|
||||
|
||||
检查同名 A、AAAA、CNAME 记录是否已经存在,域名是否位于当前 Wrangler 登录的 Cloudflare 账号中。
|
||||
|
||||
### R2 未配置
|
||||
|
||||
确认 Bucket 存在,并且 `wrangler.jsonc` 中的绑定名称为 `REPORTS`、`bucket_name` 与实际 Bucket 一致。
|
||||
|
||||
### 卡片过期或图片消失
|
||||
|
||||
默认有效期为 7 天。Worker 的定时任务会删除过期卡片的元数据、日报和缩略图,这是设计行为。
|
||||
|
||||
## 安全和隐私注意事项
|
||||
|
||||
- 日报可能包含敏感群聊内容。只分享你有权分享的内容。
|
||||
- 获得分享 URL 的人,在过期前可能查看对应日报;当前实现不是按访问者身份授权。
|
||||
- `UPLOAD_TOKEN` 是整个 Worker 的服务级密钥,不是每个用户独立的账号凭据。
|
||||
- 不要把 `UPLOAD_TOKEN`、AppSecret 或 Wrangler 凭据提交到 Git。
|
||||
- R2 保持私有,不要把 Bucket 直接公开。
|
||||
- 建议定期轮换 `UPLOAD_TOKEN`,怀疑泄露时立即轮换。
|
||||
- 微信 AppSecret 泄露时,应在微信后台重置,并立即更新 Worker Secret。
|
||||
- 自托管者自行承担 Cloudflare 用量、域名、数据合规和微信平台规则相关责任。
|
||||
|
||||
## 当前实验性限制
|
||||
|
||||
- 需要用户自己部署,普通用户无法直接开箱使用;
|
||||
- 使用一个共享的 `UPLOAD_TOKEN`,没有多用户账号系统;
|
||||
- 分享链接在有效期内属于“知道链接即可访问”;
|
||||
- 依赖微信 JS-SDK 和测试号能力,微信侧规则变化可能造成失效;
|
||||
- 当前仅上传 PNG 日报和 JPEG 缩略图;
|
||||
- 没有管理后台用于列出、提前删除或审计所有卡片;
|
||||
- 过期清理由定时任务完成,不保证到期瞬间立即删除。
|
||||
|
||||
## 代码入口
|
||||
|
||||
- Worker:`services/share-card-worker/src/index.js`
|
||||
- Worker 配置:`services/share-card-worker/wrangler.jsonc`
|
||||
- Worker 测试:`services/share-card-worker/test/index.test.js`
|
||||
- 桌面端上传:`src/main/wechat-share-card-service.ts`
|
||||
- 本地加密配置:`src/main/wechat-share-config-store.ts`
|
||||
- 分享弹窗:`src/renderer/src/components/reports/WechatShareCardDialog.tsx`
|
||||
- 共享类型:`src/shared/wechat-share-card.ts`
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)
|
||||
- [微信 JS-SDK 官方文档](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html)
|
||||
- [Cloudflare Wrangler 命令文档](https://developers.cloudflare.com/workers/wrangler/commands/)
|
||||
- [Cloudflare R2 Wrangler 命令](https://developers.cloudflare.com/workers/wrangler/commands/r2/)
|
||||
- [在 Worker 中绑定和使用 R2](https://developers.cloudflare.com/r2/api/workers/workers-api-usage/)
|
||||
- [Cloudflare Worker Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||
|
||||
常用检查:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## 你什么时候会看到这些提示
|
||||
|
||||
WechatExplorer 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
|
||||
TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
|
||||
|
||||
## 推荐步骤
|
||||
|
||||
1. 先启动 WechatExplorer,阅读连接页面显示的当前前置条件。
|
||||
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
|
||||
2. 确认微信数据目录指向当前账号。
|
||||
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
||||
4. 返回应用重新检测账号、数据库和图片资源状态。
|
||||
@@ -15,7 +15,7 @@ WechatExplorer 需要读取微信本地数据。macOS 会根据系统版本、
|
||||
|
||||
## SIP 风险
|
||||
|
||||
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 WechatExplorer 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
|
||||
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 TraceMemo 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
|
||||
|
||||
## 应用无法打开
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: setup-wechat-share-card
|
||||
description: 自动配置和部署 TraceMemo 实验性微信分享卡片服务。用户要求启用、部署、修复或迁移微信分享卡片,配置 Cloudflare Worker/R2/Wrangler,设置 UPLOAD_TOKEN、微信测试号 AppID/AppSecret、JS 接口安全域名,或希望由 Codex、Claude Code 等 Agent 代替手工阅读部署文档时使用。
|
||||
---
|
||||
|
||||
# 部署微信分享卡片
|
||||
|
||||
尽量自行发现项目状态并完成部署。只在自动检查后仍缺少必要信息时,一次性询问用户;不要逐项反复确认。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 不把真实 AppSecret、`UPLOAD_TOKEN`、Cloudflare Token 或微信验证内容写入 Git。
|
||||
- `.env.example` 只保存占位符;真实值写入项目根目录 `.env`,该文件必须被 Git 忽略。
|
||||
- 不在最终回答中回显 Secret。日志中只报告“已配置/缺失”。
|
||||
- `UPLOAD_TOKEN` 默认自动生成,不要求用户提供。
|
||||
- AppID/AppSecret 必须来自用户自己的微信测试号或公众号。无法自动获取时才询问。
|
||||
- 部署、创建 R2 和写 Secret 属于用户明确请求本 Skill 后的正常动作;不要提交、推送或创建 PR,除非用户另外明确要求。
|
||||
|
||||
## 自动工作流
|
||||
|
||||
1. 定位 TraceMemo 仓库根目录。确认存在 `services/share-card-worker/wrangler.jsonc`。
|
||||
2. 运行:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh doctor
|
||||
```
|
||||
|
||||
3. 检查根目录 `.env`。脚本会自动复用已有配置并生成缺失的 `WECHAT_SHARE_UPLOAD_TOKEN`。
|
||||
4. 如果以下值缺失,只向用户发起一次集中询问:
|
||||
- 分享域名,例如 `share.example.com`;
|
||||
- 微信测试号 AppID;
|
||||
- 微信测试号 AppSecret。
|
||||
5. 用户不知道从哪里获取时,告诉他打开:
|
||||
|
||||
```text
|
||||
https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index
|
||||
```
|
||||
|
||||
使用微信扫码登录后,复制页面上的 `appID` 和 `appsecret`。提醒用户把分享域名填入“JS 接口安全域名”,不带 `https://` 和路径。
|
||||
6. 将缺失值交给交互脚本,不要把 Secret 放进命令行参数:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh configure
|
||||
```
|
||||
|
||||
该命令通过终端交互收集缺项,AppSecret 使用隐藏输入。
|
||||
7. 执行完整部署:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh deploy
|
||||
```
|
||||
|
||||
脚本会依次:
|
||||
- 检查或临时下载 Wrangler;
|
||||
- 启动 Cloudflare OAuth 登录;
|
||||
- 执行 `whoami`;
|
||||
- 生成本地 `wrangler.local.jsonc`;
|
||||
- 创建或复用 R2 Bucket;
|
||||
- 写入三个 Worker Secret;
|
||||
- 部署 Worker;
|
||||
- 检查 `/health` 和微信签名接口。
|
||||
8. OAuth 页面出现时,让用户只完成浏览器登录/授权;不要改用 API Token,除非用户主动要求。
|
||||
9. 部署后把服务地址告诉用户,并提醒他在 TraceMemo 卡片弹窗粘贴 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN`。优先把 Token 复制到剪贴板,不在聊天中展示:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh copy-token
|
||||
```
|
||||
|
||||
10. 如果微信要求 TXT 验证文件,读取 [references/wechat-domain-verification.md](references/wechat-domain-verification.md),取得用户提供的文件后再修改 Worker。
|
||||
|
||||
## 决策规则
|
||||
|
||||
- Wrangler 未安装:优先使用项目依赖;否则通过 `pnpm dlx wrangler@latest` 临时下载,不强制全局安装。
|
||||
- `whoami` 已登录正确账号:不要重复登录。
|
||||
- R2 已存在:继续,不把“已存在”视为失败。
|
||||
- 自定义域名有 A/AAAA/CNAME 冲突:报告准确域名并要求用户选择删除冲突记录或换子域名;不要擅自删除 DNS。
|
||||
- HTTP 401:重新同步 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN` 到 Worker,再让用户更新 TraceMemo。
|
||||
- “微信 JS-SDK 尚未配置”:重新写入 AppID/AppSecret 并部署。
|
||||
- 微信返回 AppID/AppSecret 错误:让用户检查是否来自同一个测试号、AppSecret 是否已重置。
|
||||
- 缺少 JS 接口安全域名或测试号关注:这是微信后台操作,明确告诉用户要填写什么,不要假装已完成。
|
||||
|
||||
## 验证结果
|
||||
|
||||
完成前必须确认:
|
||||
|
||||
- `wrangler whoami` 成功;
|
||||
- Worker 部署成功;
|
||||
- `/health` 返回 `storage: ready`;
|
||||
- `/api/wx-signature` 返回 `appId`、`timestamp`、`nonceStr`、`signature`;
|
||||
- Git 扫描未发现 `.env`、真实 AppSecret、上传密钥或用户域名被暂存。
|
||||
|
||||
详细产品和架构说明见:`docs/deployment/experimental-wechat-share-card.md`。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "部署微信分享卡片"
|
||||
short_description: "让 Agent 自动完成微信分享卡片服务的配置与部署"
|
||||
default_prompt: "Use $setup-wechat-share-card to configure and deploy my self-hosted WeChat share-card service with minimal questions."
|
||||
@@ -0,0 +1,15 @@
|
||||
# 微信域名验证文件
|
||||
|
||||
当微信测试号页面要求下载 TXT 文件时:
|
||||
|
||||
1. 向用户索取 TXT 文件本身,或文件名与完整内容。
|
||||
2. 不把真实验证内容写进公开仓库历史。
|
||||
3. 优先在本地私有配置中注入;若当前 Worker 只能通过源码 Map 返回,则先提醒用户该值会进入工作区,确认仓库发布前必须移除或改造成 Secret/变量。
|
||||
4. 验证目标必须是:
|
||||
|
||||
```text
|
||||
https://<分享域名>/<微信提供的文件名>.txt
|
||||
```
|
||||
|
||||
5. 返回内容必须是纯文本且与微信提供内容完全一致,不加空格、HTML 或额外换行。
|
||||
6. 完成验证后再配置 JS 接口安全域名。安全域名只填主机名,不带协议或路径。
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
ENV_FILE="$REPO_ROOT/.env"
|
||||
EXAMPLE_FILE="$REPO_ROOT/.env.example"
|
||||
WORKER_DIR="$REPO_ROOT/services/share-card-worker"
|
||||
BASE_CONFIG="$WORKER_DIR/wrangler.jsonc"
|
||||
LOCAL_CONFIG="$WORKER_DIR/wrangler.local.jsonc"
|
||||
BUCKET_NAME="wechatexplorer-share-reports"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
info() {
|
||||
printf '[share-card] %s\n' "$*"
|
||||
}
|
||||
|
||||
require_project() {
|
||||
[[ -f "$BASE_CONFIG" ]] || fail "请在 TraceMemo 仓库根目录运行此脚本"
|
||||
[[ -f "$EXAMPLE_FILE" ]] || fail "缺少 .env.example"
|
||||
}
|
||||
|
||||
ensure_env_file() {
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp "$EXAMPLE_FILE" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
info "已从 .env.example 创建本机 .env"
|
||||
fi
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local line
|
||||
line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
|
||||
printf '%s' "${line#*=}"
|
||||
}
|
||||
|
||||
write_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local escaped
|
||||
escaped="$(printf '%s' "$value" | sed 's/[\\&|]/\\&/g')"
|
||||
if grep -q -E "^${key}=" "$ENV_FILE"; then
|
||||
sed -i.bak -E "s|^${key}=.*$|${key}=${escaped}|" "$ENV_FILE"
|
||||
command rm "$ENV_FILE.bak"
|
||||
else
|
||||
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
|
||||
fi
|
||||
chmod 600 "$ENV_FILE"
|
||||
}
|
||||
|
||||
normalize_domain() {
|
||||
local value="$1"
|
||||
value="${value#http://}"
|
||||
value="${value#https://}"
|
||||
value="${value%%/*}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
ensure_upload_token() {
|
||||
local token
|
||||
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
if [[ ${#token} -lt 32 ]]; then
|
||||
token="$(openssl rand -hex 32)"
|
||||
write_env_value WECHAT_SHARE_UPLOAD_TOKEN "$token"
|
||||
info "已生成新的 UPLOAD_TOKEN 并安全写入 .env"
|
||||
fi
|
||||
}
|
||||
|
||||
wrangler() {
|
||||
if [[ -x "$REPO_ROOT/node_modules/.bin/wrangler" ]]; then
|
||||
"$REPO_ROOT/node_modules/.bin/wrangler" "$@"
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm dlx wrangler@latest "$@"
|
||||
elif command -v npx >/dev/null 2>&1; then
|
||||
npx --yes wrangler@latest "$@"
|
||||
else
|
||||
fail "需要 Node.js 以及 pnpm 或 npm 才能运行 Wrangler"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_wrangler_login() {
|
||||
if wrangler whoami >/dev/null 2>&1; then
|
||||
wrangler whoami
|
||||
return
|
||||
fi
|
||||
info "即将打开 Cloudflare OAuth 登录,请在浏览器中完成授权"
|
||||
wrangler login
|
||||
wrangler whoami
|
||||
}
|
||||
|
||||
validate_required_config() {
|
||||
local domain app_id app_secret token
|
||||
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
[[ -n "$domain" && "$domain" != "share.example.com" ]] || fail "缺少真实 WECHAT_SHARE_DOMAIN"
|
||||
[[ -n "$app_id" ]] || fail "缺少 WECHAT_SHARE_APP_ID"
|
||||
[[ -n "$app_secret" ]] || fail "缺少 WECHAT_SHARE_APP_SECRET"
|
||||
[[ ${#token} -ge 32 ]] || fail "WECHAT_SHARE_UPLOAD_TOKEN 长度不足"
|
||||
}
|
||||
|
||||
configure_interactively() {
|
||||
ensure_env_file
|
||||
local domain app_id app_secret
|
||||
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
|
||||
if [[ -z "$domain" || "$domain" == "share.example.com" ]]; then
|
||||
read -r -p '分享域名(例如 share.example.com,不带 https://):' domain
|
||||
domain="$(normalize_domain "$domain")"
|
||||
[[ -n "$domain" ]] || fail "分享域名不能为空"
|
||||
write_env_value WECHAT_SHARE_DOMAIN "$domain"
|
||||
fi
|
||||
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
if [[ -z "$app_id" ]]; then
|
||||
read -r -p '微信测试号 AppID:' app_id
|
||||
[[ -n "$app_id" ]] || fail "AppID 不能为空"
|
||||
write_env_value WECHAT_SHARE_APP_ID "$app_id"
|
||||
fi
|
||||
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
if [[ -z "$app_secret" ]]; then
|
||||
read -r -s -p '微信测试号 AppSecret(输入不会显示):' app_secret
|
||||
printf '\n'
|
||||
[[ -n "$app_secret" ]] || fail "AppSecret 不能为空"
|
||||
write_env_value WECHAT_SHARE_APP_SECRET "$app_secret"
|
||||
fi
|
||||
|
||||
ensure_upload_token
|
||||
info "本机配置已准备完成"
|
||||
}
|
||||
|
||||
generate_local_config() {
|
||||
local domain
|
||||
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
|
||||
cat > "$LOCAL_CONFIG" <<EOF
|
||||
{
|
||||
"\$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "wechatexplorer-share-card",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-07-23",
|
||||
"routes": [{ "pattern": "$domain", "custom_domain": true }],
|
||||
"r2_buckets": [{ "binding": "REPORTS", "bucket_name": "$BUCKET_NAME" }],
|
||||
"triggers": { "crons": ["17 3 * * *"] },
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://$domain",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
info "已生成本机 Worker 配置 services/share-card-worker/wrangler.local.jsonc"
|
||||
}
|
||||
|
||||
assert_secrets_not_tracked() {
|
||||
git check-ignore -q .env || fail ".env 未被 Git 忽略,请停止部署并检查 .gitignore"
|
||||
if git ls-files --error-unmatch .env >/dev/null 2>&1; then
|
||||
fail ".env 已被 Git 跟踪,请先从索引移除"
|
||||
fi
|
||||
if git diff --cached --name-only | grep -Eq '(^|/)\.env$|wrangler\.local\.jsonc$'; then
|
||||
fail "敏感本机配置已被暂存,请先取消暂存"
|
||||
fi
|
||||
}
|
||||
|
||||
create_bucket_if_needed() {
|
||||
local output
|
||||
set +e
|
||||
output="$(wrangler r2 bucket create "$BUCKET_NAME" --config "$LOCAL_CONFIG" 2>&1)"
|
||||
local status=$?
|
||||
set -e
|
||||
if [[ $status -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
elif printf '%s' "$output" | grep -Eqi 'already exists|already owned|10004'; then
|
||||
info "R2 Bucket 已存在,继续部署"
|
||||
else
|
||||
printf '%s\n' "$output" >&2
|
||||
fail "创建 R2 Bucket 失败"
|
||||
fi
|
||||
}
|
||||
|
||||
put_secrets() {
|
||||
local upload_token app_id app_secret
|
||||
upload_token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
printf '%s' "$upload_token" | wrangler secret put UPLOAD_TOKEN --config "$LOCAL_CONFIG"
|
||||
printf '%s' "$app_id" | wrangler secret put WECHAT_APP_ID --config "$LOCAL_CONFIG"
|
||||
printf '%s' "$app_secret" | wrangler secret put WECHAT_APP_SECRET --config "$LOCAL_CONFIG"
|
||||
}
|
||||
|
||||
verify_service() {
|
||||
local domain health signature
|
||||
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
|
||||
health="$(curl -fsS --retry 5 --retry-delay 2 "https://$domain/health")"
|
||||
printf '%s' "$health" | grep -q '"storage":"ready"' || fail "健康检查未返回 storage: ready"
|
||||
signature="$(curl -fsS --retry 3 --retry-delay 2 "https://$domain/api/wx-signature?url=https%3A%2F%2F${domain}%2Fhealth")"
|
||||
printf '%s' "$signature" | grep -q '"signature"' || fail "微信 JS-SDK 签名检查失败:$signature"
|
||||
info "服务验证成功:https://$domain"
|
||||
}
|
||||
|
||||
doctor() {
|
||||
require_project
|
||||
ensure_env_file
|
||||
ensure_upload_token
|
||||
assert_secrets_not_tracked
|
||||
info "Node: $(node --version 2>/dev/null || printf '未安装')"
|
||||
info "pnpm: $(pnpm --version 2>/dev/null || printf '未安装')"
|
||||
if wrangler --version >/dev/null 2>&1; then
|
||||
info "Wrangler 可用:$(wrangler --version | tail -n 1)"
|
||||
else
|
||||
fail "Wrangler 无法运行"
|
||||
fi
|
||||
local domain app_id app_secret
|
||||
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
[[ -n "$domain" && "$domain" != "share.example.com" ]] && info "分享域名:已配置" || info "分享域名:缺失"
|
||||
[[ -n "$app_id" ]] && info "微信 AppID:已配置" || info "微信 AppID:缺失"
|
||||
[[ -n "$app_secret" ]] && info "微信 AppSecret:已配置" || info "微信 AppSecret:缺失"
|
||||
info "UPLOAD_TOKEN:已配置"
|
||||
}
|
||||
|
||||
deploy() {
|
||||
require_project
|
||||
ensure_env_file
|
||||
ensure_upload_token
|
||||
validate_required_config
|
||||
assert_secrets_not_tracked
|
||||
ensure_wrangler_login
|
||||
generate_local_config
|
||||
create_bucket_if_needed
|
||||
put_secrets
|
||||
wrangler deploy --config "$LOCAL_CONFIG"
|
||||
verify_service
|
||||
}
|
||||
|
||||
copy_token() {
|
||||
ensure_env_file
|
||||
ensure_upload_token
|
||||
local token
|
||||
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
if command -v pbcopy >/dev/null 2>&1; then
|
||||
printf '%s' "$token" | pbcopy
|
||||
elif command -v wl-copy >/dev/null 2>&1; then
|
||||
printf '%s' "$token" | wl-copy
|
||||
elif command -v xclip >/dev/null 2>&1; then
|
||||
printf '%s' "$token" | xclip -selection clipboard
|
||||
else
|
||||
fail "未找到剪贴板工具;请让用户自行从 .env 读取 WECHAT_SHARE_UPLOAD_TOKEN"
|
||||
fi
|
||||
info "UPLOAD_TOKEN 已复制到剪贴板"
|
||||
}
|
||||
|
||||
case "${1:-doctor}" in
|
||||
doctor) doctor ;;
|
||||
configure) configure_interactively ;;
|
||||
deploy) deploy ;;
|
||||
copy-token) copy_token ;;
|
||||
*) fail "用法:$0 {doctor|configure|deploy|copy-token}" ;;
|
||||
esac
|
||||
@@ -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,只需要完成一条主线:
|
||||
|
||||
> 安装应用 → 连接微信数据 → 确认聊天已加载 → 搜索或提问。
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
|
||||
|
||||
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
|
||||
- WechatExplorer 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||
|
||||
@@ -26,23 +26,23 @@
|
||||
|
||||
### Windows
|
||||
|
||||
1. 从 Releases 下载 Windows x64 的 `WechatExplorer-<版本号>-setup.exe` 安装包。
|
||||
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。
|
||||
2. 双击安装包,按向导完成安装。
|
||||
3. 启动 WechatExplorer。
|
||||
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 WechatExplorer。
|
||||
3. 启动 TraceMemo。
|
||||
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
|
||||
|
||||
### macOS
|
||||
|
||||
1. 根据处理器下载对应的 `.dmg`:Apple Silicon(M 系列)选择 `arm64`,Intel Mac 选择 `x64`。
|
||||
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
|
||||
1. 下载 Apple Silicon(M 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。
|
||||
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,可在终端执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
xattr -cr "/Applications/TraceMemo.app"
|
||||
```
|
||||
|
||||
5. 启动 WechatExplorer。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
|
||||
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
|
||||
|
||||
@@ -107,12 +107,12 @@
|
||||
- [转写微信语音](./voice.md)
|
||||
- [导出聊天档案](./export.md)
|
||||
- [可选开启防撤回](./recall-protection.md)
|
||||
- [在微信里向 WechatExplorer 提问](../agent/agent-hub.md)
|
||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||
|
||||
## 7. 想直接在微信里提问
|
||||
|
||||
如果你希望直接在微信里向 WechatExplorer 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub:
|
||||
如果你希望直接在微信里向 TraceMemo 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub:
|
||||
|
||||
1. 先完成上面的微信数据库连接,并确认“档案”里能看到聊天。
|
||||
2. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
如果你经常查同一批工作群、项目讨论或长期联系人,只靠每次临时翻聊天会越来越慢。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。
|
||||
|
||||
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 WechatExplorer 控制,请查看你所选服务商的政策。
|
||||
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 TraceMemo 控制,请查看你所选服务商的政策。
|
||||
|
||||
Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的日志和配置仍由你负责。
|
||||
|
||||
@@ -47,7 +47,7 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
|
||||
|
||||
外部 Agent 通过 Reader Skill 调用本机 API,按需读取联系人、会话或时间范围内的聊天;它不会因此获得数据库文件路径或任意文件系统权限。Agent 是否把读取结果再次发送给模型,取决于 Agent 本身及其配置。
|
||||
|
||||
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 WechatExplorer,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
|
||||
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 TraceMemo,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
|
||||
|
||||
机器人收到的文字会先进入本机 Agent Hub;如果任务需要总结或自然语言理解,受控上下文可能发送给你配置的 AI Provider。机器人账号扫码登录、个人微信数据库连接和外部 Agent/API Token 是不同的边界,使用前请分别确认账号与权限。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 防撤回
|
||||
|
||||
防撤回是一个默认关闭的可选功能。开启后,WechatExplorer 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
||||
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
||||
|
||||
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
||||
2. 打开“设置 → 防撤回”。
|
||||
3. 阅读性能和数据提示后,开启“防撤回”。
|
||||
4. 保持 WechatExplorer 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
||||
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
||||
|
||||
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
- 监听应用能够识别到的后续撤回变化;
|
||||
- 在本地保留必要的消息和撤回关系;
|
||||
- 将已识别的原消息与撤回状态一起显示在档案中;
|
||||
- 按微信账号隔离 WechatExplorer 保存的恢复记录。
|
||||
- 按微信账号隔离 TraceMemo 保存的恢复记录。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
||||
- WechatExplorer 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
||||
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
||||
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
||||
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
||||
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
||||
|
||||
## 数据写入与关闭边界
|
||||
|
||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 WechatExplorer 的用户数据目录保存必要的本地恢复记录。
|
||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
||||
|
||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 生成群聊日报和总结
|
||||
|
||||
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 WechatExplorer 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
|
||||
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 TraceMemo 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
|
||||
|
||||
## 报告适合做什么
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
|
||||
1. 确认下载的是 GitHub Releases 中的 Windows x64 `-setup.exe`,并已完成安装。
|
||||
2. 安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
|
||||
3. 安装完成后重新启动 WechatExplorer;如果仍无响应,再重新运行安装包进行覆盖安装。
|
||||
3. 安装完成后重新启动 TraceMemo;如果仍无响应,再重新运行安装包进行覆盖安装。
|
||||
|
||||
### macOS
|
||||
|
||||
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
|
||||
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/WechatExplorer.app"`。
|
||||
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
|
||||
|
||||
完整安装步骤见[第一次使用 WechatExplorer](./getting-started.md#2-安装并启动)。
|
||||
完整安装步骤见[第一次使用 TraceMemo](./getting-started.md#2-安装并启动)。
|
||||
|
||||
## 连接微信失败
|
||||
|
||||
@@ -69,7 +69,7 @@ AI Search 失败时可能仍保留部分来源;不要把部分结果当成完
|
||||
|
||||
确认:
|
||||
|
||||
1. WechatExplorer 正在运行且 API Center 显示本地服务在线;
|
||||
1. TraceMemo 正在运行且 API Center 显示本地服务在线;
|
||||
2. Agent 使用的是当前 Reader Skill,而不是旧的 MCP 配置;
|
||||
3. 请求地址为 `http://127.0.0.1:6131`;
|
||||
4. 非 health 请求带有最新 `Authorization: Bearer <TOKEN>`;
|
||||
@@ -91,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
|
||||
productName: WechatExplorer
|
||||
appId: com.tracememo.app
|
||||
productName: TraceMemo
|
||||
afterPack: scripts/after-pack.cjs
|
||||
directories:
|
||||
buildResources: build
|
||||
@@ -25,15 +25,15 @@ extraResources:
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- from: docs/skill/wechatexplorer-reader
|
||||
to: skill/wechatexplorer-reader
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
- '**/*'
|
||||
win:
|
||||
icon: icon.ico
|
||||
# WCDB's Windows runtime checks the host executable name. The dev runtime is
|
||||
# electron.exe, so keep the packaged executable compatible while preserving
|
||||
# WechatExplorer as the product/shortcut name.
|
||||
# electron.exe, so keep the packaged executable compatible while using
|
||||
# TraceMemo as the product/shortcut name.
|
||||
executableName: electron
|
||||
nsis:
|
||||
oneClick: false
|
||||
@@ -46,10 +46,10 @@ mac:
|
||||
icon: icon.icns
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
# The bundled WCDB bridge accepts Electron as its internal host name. The
|
||||
# public app name and bundle identifier remain WechatExplorer-specific.
|
||||
# The bundled WCDB bridge still uses Electron as its internal executable
|
||||
# compatibility name; the public product and bundle identity are TraceMemo.
|
||||
CFBundleName: Electron
|
||||
CFBundleDisplayName: WechatExplorer
|
||||
CFBundleDisplayName: TraceMemo
|
||||
NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
|
||||
@@ -44,6 +44,7 @@ const api = {
|
||||
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
|
||||
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
|
||||
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
|
||||
selectExportDirectory: () => electron.ipcRenderer.invoke("export:selectDirectory"),
|
||||
onExportProgress: (callback) => {
|
||||
const listener = (_event, progress) => callback(progress);
|
||||
electron.ipcRenderer.on("export:progress", listener);
|
||||
|
||||
+10
-8
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.1.9",
|
||||
"name": "tracememo",
|
||||
"version": "2.2.2",
|
||||
"packageManager": "pnpm@7.33.7",
|
||||
"description": "macOS / Windows 本地优先的微信聊天记录搜索与 AI 工作台",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"wechat chat",
|
||||
@@ -20,7 +20,7 @@
|
||||
"author": "Qingmao",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
|
||||
"url": "https://github.com/Wxw-Gu/TraceMemo.git"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
@@ -49,16 +49,15 @@
|
||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
||||
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
|
||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
|
||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
|
||||
"build:native-services": "npm run build:wechat-connector",
|
||||
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
|
||||
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"release": "npm run release:mac && npm run release:win",
|
||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||
@@ -79,6 +78,7 @@
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sherpa-onnx-node": "1.13.3",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
@@ -96,6 +96,7 @@
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -114,7 +115,8 @@
|
||||
"sass": "^1.102.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.10",
|
||||
"wrangler": "^4.28.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
|
||||
Generated
+856
-14
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
File diff suppressed because it is too large
Load Diff
@@ -464,32 +464,6 @@
|
||||
font-style: normal;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.gallery-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gallery-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.gallery-stats {
|
||||
display: inline-flex;
|
||||
margin-top: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -644,7 +618,6 @@
|
||||
padding: 11px;
|
||||
}
|
||||
.compact .important-card,
|
||||
.compact .gallery-card,
|
||||
.compact .chat-block {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -801,11 +774,6 @@
|
||||
{{VISION_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群相册</div>
|
||||
{{GALLERY_CARDS}}
|
||||
{{GALLERY_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音之最</div>
|
||||
|
||||
@@ -76,6 +76,17 @@
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.avatar-grid.avatar-count-1 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.avatar-grid.avatar-count-2 {
|
||||
height: 28px;
|
||||
}
|
||||
.avatar-grid.empty-section {
|
||||
display: none;
|
||||
}
|
||||
.avatar-grid img,
|
||||
.avatar {
|
||||
width: 100%;
|
||||
@@ -523,7 +534,7 @@
|
||||
<div class="overview">{{OVERVIEW}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||
@@ -583,7 +594,7 @@
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||
数据来源:TraceMemo · 微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.avatar-grid.avatar-count-1 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.avatar-grid.avatar-count-2 {
|
||||
height: 28px;
|
||||
}
|
||||
.avatar-grid.empty-section {
|
||||
display: none;
|
||||
}
|
||||
.avatar-grid img,
|
||||
.avatar {
|
||||
width: 100%;
|
||||
@@ -454,32 +465,6 @@
|
||||
font-style: normal;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.gallery-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gallery-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.gallery-stats {
|
||||
display: inline-flex;
|
||||
margin-top: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -634,7 +619,6 @@
|
||||
padding: 11px;
|
||||
}
|
||||
.compact .important-card,
|
||||
.compact .gallery-card,
|
||||
.compact .chat-block {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -695,7 +679,7 @@
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="hero-headline">
|
||||
<b>{{HERO_HEADLINE}}</b>
|
||||
@@ -790,11 +774,6 @@
|
||||
{{VISION_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群相册</div>
|
||||
{{GALLERY_CARDS}}
|
||||
{{GALLERY_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音之最</div>
|
||||
@@ -820,7 +799,7 @@
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||
数据来源:TraceMemo · 微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
|
||||
@@ -88,7 +88,9 @@ function validateAsarRuntimeDependencies(runtimeResources) {
|
||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||
|
||||
const entries = new Set(asar.listPackage(asarPath))
|
||||
// @electron/asar returns platform-native separators. Normalize to POSIX
|
||||
// paths so validation behaves consistently on Windows and macOS/Linux.
|
||||
const entries = new Set(asar.listPackage(asarPath).map((entry) => entry.replaceAll('\\', '/')))
|
||||
const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter(
|
||||
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
|
||||
)
|
||||
@@ -104,9 +106,9 @@ function setPlistValue(plistPath, key, value) {
|
||||
}
|
||||
|
||||
function validateReaderSkillRuntime(runtimeResources) {
|
||||
const skillPath = path.join(runtimeResources, 'skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||
const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
|
||||
if (!existsSync(skillPath)) {
|
||||
throw new Error(`Missing bundled WechatExplorer Reader Skill: ${skillPath}`)
|
||||
throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`)
|
||||
}
|
||||
return skillPath
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { JSDOM } = require('jsdom')
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: 'mobile-feed',
|
||||
className: 'template-mobile-feed',
|
||||
label: 'Mobile 01',
|
||||
name: '微信信息流',
|
||||
width: 390
|
||||
},
|
||||
{
|
||||
id: 'mobile-magazine',
|
||||
className: 'template-mobile-magazine',
|
||||
label: 'Mobile 02',
|
||||
name: 'AI Magazine',
|
||||
width: 390
|
||||
},
|
||||
{
|
||||
id: 'mobile-dashboard',
|
||||
className: 'template-mobile-dashboard',
|
||||
label: 'Mobile 03',
|
||||
name: 'AI Command Center',
|
||||
width: 390
|
||||
},
|
||||
{
|
||||
id: 'desktop-workspace',
|
||||
className: 'template-desktop-workspace',
|
||||
label: 'Desktop 01',
|
||||
name: '三栏 AI 工作台',
|
||||
width: 1440
|
||||
},
|
||||
{
|
||||
id: 'desktop-editorial',
|
||||
className: 'template-desktop-editorial',
|
||||
label: 'Desktop 02',
|
||||
name: 'Editorial 科技日报',
|
||||
width: 1440
|
||||
}
|
||||
]
|
||||
|
||||
const sourcePath = path.resolve(process.argv[2] || '')
|
||||
const outputDir = path.resolve(
|
||||
process.argv[3] || path.join(process.cwd(), '.codex', 'report-template-preview')
|
||||
)
|
||||
const templatePath = path.join(process.cwd(), 'resources', 'daily_report_templates.html')
|
||||
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||
console.error(
|
||||
'用法: node scripts/generate-report-template-preview.cjs <现有日报.html> [输出目录]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
console.error(`模板资源不存在: ${templatePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const document = new JSDOM(fs.readFileSync(sourcePath, 'utf8')).window.document
|
||||
const templateHtml = fs.readFileSync(templatePath, 'utf8')
|
||||
|
||||
const one = (selector) => document.querySelector(selector)
|
||||
const html = (selector) => one(selector)?.innerHTML || ''
|
||||
const childrenAfterTitle = (selector) => {
|
||||
const section = one(selector)
|
||||
if (!section) return ''
|
||||
return Array.from(section.children)
|
||||
.filter((child) => !child.classList.contains('section-title'))
|
||||
.map((child) => child.outerHTML)
|
||||
.join('')
|
||||
}
|
||||
const sectionClass = (selector) => {
|
||||
const section = one(selector)
|
||||
return !section || section.classList.contains('empty-section') ? 'empty-section' : ''
|
||||
}
|
||||
|
||||
const statValues = Array.from(document.querySelectorAll('.stat')).map((node) => {
|
||||
const strong = node.querySelector('strong')?.textContent?.trim()
|
||||
if (strong) return strong
|
||||
return node.textContent?.trim().match(/[\d.]+\s*h?/i)?.[0] || ''
|
||||
})
|
||||
const title = one('.hero h1')?.textContent?.trim() || document.title
|
||||
const subItems = Array.from(document.querySelectorAll('.sub > *'))
|
||||
const dateTimeRange = subItems[0]?.textContent?.trim() || ''
|
||||
const reportDate = dateTimeRange.match(/\d{4}-\d{2}-\d{2}/)?.[0] || ''
|
||||
const recordNote = one('.record-note')?.textContent?.trim() || ''
|
||||
const overview = one('.overview')?.textContent?.trim() || ''
|
||||
const footer = one('.footer')?.textContent?.trim().replace(/\s+/g, ' ') || ''
|
||||
const generatedAt = footer.match(/生成时间[::]\s*([^基]+?)(?:基于|$)/)?.[1]?.trim() || ''
|
||||
const activityLine = Array.from(document.querySelectorAll('.analytics > .card')).find((node) =>
|
||||
node.textContent?.includes('活跃时间线')
|
||||
)
|
||||
|
||||
const values = {
|
||||
REPORT_TITLE: title,
|
||||
REPORT_DATE: reportDate,
|
||||
DATE_RANGE: '今天',
|
||||
TIME_SPAN: statValues[2] || dateTimeRange,
|
||||
HERO_SUMMARY: overview,
|
||||
HERO_TAKEAWAY: '',
|
||||
HERO_PENDING: '',
|
||||
HERO_STATUS_LINE: '',
|
||||
HERO_AVATARS: html('.avatar-grid'),
|
||||
HERO_AVATAR_CLASS: sectionClass('.avatar-grid'),
|
||||
MESSAGE_COUNT: statValues[0] || '',
|
||||
ACTIVE_USERS: statValues[1] || '',
|
||||
TOPIC_COUNT: statValues[3] || '',
|
||||
RECORD_NOTE: recordNote,
|
||||
GENERATED_AT: generatedAt,
|
||||
FOOTER_NOTE: footer,
|
||||
TOPIC_CARDS: childrenAfterTitle('.topics'),
|
||||
IMPORTANT_MESSAGES: childrenAfterTitle('.messages'),
|
||||
QUOTE_BLOCKS: childrenAfterTitle('.quotes'),
|
||||
QA_CARDS: childrenAfterTitle('.qa'),
|
||||
HEAT_BARS: Array.from(document.querySelectorAll('.analytics > .heat-row'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join(''),
|
||||
RANK_ITEMS: Array.from(document.querySelectorAll('.analytics .rank'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join(''),
|
||||
ACTIVITY_TIMELINE: activityLine?.textContent?.trim() || '',
|
||||
CLOUD_TAGS: html('.cloud-tags'),
|
||||
RESOURCE_ITEMS: childrenAfterTitle('.resources'),
|
||||
TODO_CARDS: '',
|
||||
UNRESOLVED_CARDS: '',
|
||||
STORYLINE_CARDS: '',
|
||||
REVERSAL_CARDS: '',
|
||||
CHAIN_CARDS: '',
|
||||
VISION_TITLE: 'AI 识别的图片精选',
|
||||
VISION_CARDS: childrenAfterTitle('.vision'),
|
||||
VOICE_CARDS: '',
|
||||
VOICE_RANK_CARDS: '',
|
||||
BADGE_CARDS: '',
|
||||
KEYWORDS_EMPTY_CLASS: sectionClass('.cloud'),
|
||||
ANALYTICS_EMPTY_CLASS: sectionClass('.analytics'),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass('.messages'),
|
||||
TOPICS_EMPTY_CLASS: sectionClass('.topics'),
|
||||
QUOTES_EMPTY_CLASS: sectionClass('.quotes'),
|
||||
RESOURCES_EMPTY_CLASS: sectionClass('.resources'),
|
||||
QA_EMPTY_CLASS: sectionClass('.qa'),
|
||||
ACTIONS_EMPTY_CLASS: 'empty-section',
|
||||
STORYLINES_EMPTY_CLASS: 'empty-section',
|
||||
REVERSALS_EMPTY_CLASS: 'empty-section',
|
||||
CHAINS_EMPTY_CLASS: 'empty-section',
|
||||
VISION_EMPTY_CLASS: sectionClass('.vision'),
|
||||
VOICE_EMPTY_CLASS: 'empty-section',
|
||||
VOICE_RANK_EMPTY_CLASS: 'empty-section',
|
||||
BADGES_EMPTY_CLASS: 'empty-section',
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section',
|
||||
HERO_PENDING_EMPTY_CLASS: 'empty-section',
|
||||
HERO_STATUS_EMPTY_CLASS: 'empty-section'
|
||||
}
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const render = (template) => {
|
||||
let result = templateHtml
|
||||
const replacements = {
|
||||
...values,
|
||||
TEMPLATE_CLASS: template.className,
|
||||
TEMPLATE_LABEL: template.label,
|
||||
TEMPLATE_NAME: template.name
|
||||
}
|
||||
const htmlKeys = new Set([
|
||||
'HERO_AVATARS',
|
||||
'TOPIC_CARDS',
|
||||
'IMPORTANT_MESSAGES',
|
||||
'QUOTE_BLOCKS',
|
||||
'QA_CARDS',
|
||||
'HEAT_BARS',
|
||||
'RANK_ITEMS',
|
||||
'CLOUD_TAGS',
|
||||
'RESOURCE_ITEMS',
|
||||
'TODO_CARDS',
|
||||
'UNRESOLVED_CARDS',
|
||||
'STORYLINE_CARDS',
|
||||
'REVERSAL_CARDS',
|
||||
'CHAIN_CARDS',
|
||||
'VISION_CARDS',
|
||||
'VOICE_CARDS',
|
||||
'VOICE_RANK_CARDS',
|
||||
'BADGE_CARDS'
|
||||
])
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
const safeValue = htmlKeys.has(key) ? String(value || '') : escapeHtml(value)
|
||||
result = result.replaceAll(`{{${key}}}`, safeValue)
|
||||
}
|
||||
return result.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
for (const template of templates) {
|
||||
fs.writeFileSync(path.join(outputDir, `${template.id}.html`), render(template), 'utf8')
|
||||
}
|
||||
|
||||
const reportName = title.replace(/日报$/, '')
|
||||
const buttons = templates
|
||||
.map(
|
||||
(template, index) => `
|
||||
<button class="${index === 0 ? 'active' : ''}" data-src="${template.id}.html" data-width="${template.width}">
|
||||
<span>${template.label}</span><b>${template.name}</b>
|
||||
</button>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const indexHtml = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(reportName)} · 五套日报模板预览</title>
|
||||
<style>
|
||||
*{box-sizing:border-box} body{margin:0;background:#e9eeeb;color:#17211d;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
|
||||
header{position:sticky;top:0;z-index:2;padding:18px 24px 14px;background:rgba(255,255,255,.95);border-bottom:1px solid #d7e0da;backdrop-filter:blur(14px)}
|
||||
h1{margin:0;font-size:20px} p{margin:5px 0 0;color:#68736c;font-size:12px}.toolbar{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding-bottom:2px}
|
||||
button{display:grid;flex:0 0 auto;gap:2px;min-width:142px;padding:9px 12px;border:1px solid #d8e1db;border-radius:9px;background:#fff;color:#24332b;text-align:left;cursor:pointer}
|
||||
button span{color:#708078;font-size:9px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}button b{font-size:12px}button.active{border-color:#16835b;background:#eaf6ef;color:#0d6744}
|
||||
.stage{display:flex;justify-content:center;min-height:calc(100vh - 132px);padding:24px;overflow:auto}.frame-shell{width:390px;max-width:100%;overflow:hidden;border:1px solid #cbd6cf;border-radius:14px;background:white;box-shadow:0 18px 48px rgba(30,55,43,.15);transition:width .2s ease}
|
||||
iframe{display:block;width:100%;height:calc(100vh - 180px);min-height:680px;border:0;background:white}
|
||||
@media(max-width:640px){header{padding:14px 12px 12px}.stage{padding:12px}.frame-shell{border-radius:10px}button{min-width:132px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>${escapeHtml(reportName)} · 五套日报模板</h1><p>同一份 2026-08-11 真实日报数据,可直接切换比较布局、排版和信息密度。</p><div class="toolbar">${buttons}</div></header>
|
||||
<main class="stage"><div class="frame-shell"><iframe title="日报模板预览" src="mobile-feed.html"></iframe></div></main>
|
||||
<script>
|
||||
const frame=document.querySelector('iframe');const shell=document.querySelector('.frame-shell');
|
||||
document.querySelectorAll('button').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('button').forEach(item=>item.classList.remove('active'));button.classList.add('active');frame.src=button.dataset.src;shell.style.width=button.dataset.width+'px'}));
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, 'index.html'), indexHtml, 'utf8')
|
||||
console.log(path.join(outputDir, 'index.html'))
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# WechatExplorer v2.1.9 Local HTTP API 手动验收脚本
|
||||
# TraceMemo v2.2.0 Local HTTP API 手动验收脚本
|
||||
# 仅用于 macOS Terminal;不会写入或输出真实 API Token。
|
||||
|
||||
set -u
|
||||
|
||||
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:6131}"
|
||||
API_BASE_URL="${API_BASE_URL%/}"
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wechatexplorer-api-test.XXXXXX")"
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tracememo-api-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
PASS_COUNT=0
|
||||
@@ -18,7 +18,7 @@ pass() { PASS_COUNT=$((PASS_COUNT + 1)); printf 'PASS %s\n' "$1"; }
|
||||
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); printf 'FAIL %s%s\n' "$1" "${2:+ ($2)}"; }
|
||||
skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); printf 'SKIP %s\n' "$1"; }
|
||||
|
||||
printf 'WechatExplorer Local HTTP API 手动测试\n'
|
||||
printf 'TraceMemo Local HTTP API 手动测试\n'
|
||||
printf 'API 地址: %s\n\n' "$API_BASE_URL"
|
||||
read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN
|
||||
printf '\n'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
|
||||
const outputDir = path.join(os.tmpdir(), 'wechatexplorer-report-fixtures')
|
||||
const outputDir = path.join(os.tmpdir(), 'tracememo-report-fixtures')
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
@@ -182,7 +182,6 @@ fullRequest.report.sectionMeta = {
|
||||
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
|
||||
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
|
||||
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
|
||||
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
|
||||
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
|
||||
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
|
||||
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
|
||||
@@ -218,24 +217,7 @@ fullRequest.report.reversals = [
|
||||
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
|
||||
]
|
||||
fullRequest.report.media = {
|
||||
gallery: [
|
||||
{
|
||||
sender: '阿宇',
|
||||
time: '09:52',
|
||||
imageUrl: sampleImage,
|
||||
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
|
||||
stats: '12 条后续消息 · 6 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
},
|
||||
{
|
||||
sender: '佩佩',
|
||||
time: '17:14',
|
||||
imageUrl: sampleImage,
|
||||
note: '第二张图带起一轮轻松但有效的快速确认。',
|
||||
stats: '5 条后续消息 · 3 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
}
|
||||
],
|
||||
gallery: [],
|
||||
voiceHighlights: [
|
||||
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
|
||||
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
|
||||
@@ -281,7 +263,6 @@ async function renderRequest(request, targetBase) {
|
||||
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
|
||||
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
|
||||
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
|
||||
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
|
||||
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
|
||||
@@ -343,9 +324,6 @@ async function renderRequest(request, targetBase) {
|
||||
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
|
||||
REVERSAL_CARDS: reversalCards,
|
||||
REVERSALS_MORE_NOTE: '',
|
||||
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: '',
|
||||
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: '',
|
||||
|
||||
@@ -26,15 +26,15 @@ new Function('exports', 'require', 'module', output)(moduleExports, require, {
|
||||
const { buildSkillInstallInstruction } = moduleExports
|
||||
const local = {
|
||||
type: 'local',
|
||||
directoryPath: 'C:/skill/wechatexplorer-reader',
|
||||
skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md',
|
||||
directoryPath: 'C:/skill/tracememo-reader',
|
||||
skillPath: 'C:/skill/tracememo-reader/SKILL.md',
|
||||
version: 'v1.0'
|
||||
}
|
||||
|
||||
for (const [target, expected] of [
|
||||
['codex', 'Codex 项目或用户 Skill 目录'],
|
||||
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
|
||||
['openclaw', '作为 WechatExplorer Reader Skill 安装'],
|
||||
['openclaw', '作为 TraceMemo Reader Skill 安装'],
|
||||
['generic', '读取并安装']
|
||||
]) {
|
||||
const text = buildSkillInstallInstruction({
|
||||
@@ -44,6 +44,7 @@ for (const [target, expected] of [
|
||||
})
|
||||
assert.match(text, new RegExp(expected))
|
||||
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
|
||||
assert.match(text, /TRACEMEMO_API_TOKEN/)
|
||||
assert.match(text, /WECHATEXPLORER_API_TOKEN/)
|
||||
assert.match(text, /Authorization: Bearer/)
|
||||
assert.doesNotMatch(text, /mcpServers/)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# WechatExplorer share-card worker
|
||||
|
||||
Cloudflare Worker + private R2 service for temporary WeChat report cards.
|
||||
|
||||
This is an experimental, self-hosted feature. See the complete Chinese deployment guide:
|
||||
|
||||
- `../../docs/deployment/experimental-wechat-share-card.md`
|
||||
|
||||
Required encrypted secrets:
|
||||
|
||||
- `WECHAT_APP_ID`
|
||||
- `WECHAT_APP_SECRET`
|
||||
- `UPLOAD_TOKEN` (random 32+ character token also saved in WechatExplorer's secure settings)
|
||||
|
||||
Create the private bucket, set secrets, and deploy:
|
||||
|
||||
```bash
|
||||
npx wrangler r2 bucket create wechatexplorer-share-reports
|
||||
npx wrangler secret put WECHAT_APP_ID
|
||||
npx wrangler secret put WECHAT_APP_SECRET
|
||||
npx wrangler secret put UPLOAD_TOKEN
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
Keep the R2 public development URL disabled. All reads go through the Worker and expire with
|
||||
the card.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "wechatexplorer-share-card-worker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"check": "node --check src/index.js && node --test test/index.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.28.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
const encoder = new TextEncoder()
|
||||
const DAY_MS = 86_400_000
|
||||
// Add the TXT filename and content supplied by the WeChat test-account page when
|
||||
// domain verification is required. Never commit a real verification value.
|
||||
const WECHAT_DOMAIN_VERIFICATION = new Map()
|
||||
|
||||
const json = (value, init = {}) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', ...(init.headers || {}) }
|
||||
})
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
|
||||
const safeJson = (value) => JSON.stringify(value).replaceAll('<', '\\u003c')
|
||||
|
||||
const cardKey = (id) => `cards/${id}/card.json`
|
||||
const imageKey = (id) => `cards/${id}/report.png`
|
||||
const thumbnailKey = (id) => `cards/${id}/thumbnail.jpg`
|
||||
|
||||
const storageUnavailable = () =>
|
||||
json(
|
||||
{
|
||||
error: '图片存储服务尚未启用,请在 Cloudflare 控制台启用 R2 后重新生成分享卡片'
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
|
||||
const readCard = async (env, id) => {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) return null
|
||||
const object = await env.REPORTS.get(cardKey(id))
|
||||
if (!object) return null
|
||||
const card = JSON.parse(await object.text())
|
||||
if (Date.parse(card.expiresAt) <= Date.now()) {
|
||||
await deleteCard(env, id)
|
||||
return null
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
const deleteCard = async (env, id) => {
|
||||
await env.REPORTS.delete([cardKey(id), imageKey(id), thumbnailKey(id)])
|
||||
}
|
||||
|
||||
const bearerAuthorized = (request, env) => {
|
||||
const value = request.headers.get('authorization') || ''
|
||||
return Boolean(env.UPLOAD_TOKEN) && value === `Bearer ${env.UPLOAD_TOKEN}`
|
||||
}
|
||||
|
||||
const publicOrigin = (request, env) =>
|
||||
String(env.PUBLIC_ORIGIN || new URL(request.url).origin).replace(/\/+$/, '')
|
||||
|
||||
const createCard = async (request, env) => {
|
||||
if (!bearerAuthorized(request, env)) return json({ error: '未授权' }, { status: 401 })
|
||||
const body = await request.json().catch(() => null)
|
||||
if (!body) return json({ error: '请求体无效' }, { status: 400 })
|
||||
const title = String(body.title || '')
|
||||
.trim()
|
||||
.slice(0, 64)
|
||||
const description = String(body.description || '')
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
if (!title || !body.imageBase64 || !body.thumbnailBase64) {
|
||||
return json({ error: '缺少标题或图片' }, { status: 400 })
|
||||
}
|
||||
const image = Uint8Array.from(atob(body.imageBase64), (char) => char.charCodeAt(0))
|
||||
const thumbnail = Uint8Array.from(atob(body.thumbnailBase64), (char) => char.charCodeAt(0))
|
||||
if (image.byteLength > 25 * 1024 * 1024 || thumbnail.byteLength > 2 * 1024 * 1024) {
|
||||
return json({ error: '图片超过大小限制' }, { status: 413 })
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const days = Math.max(1, Math.min(30, Number(body.expiresInDays || env.DEFAULT_EXPIRY_DAYS || 7)))
|
||||
const createdAt = new Date().toISOString()
|
||||
const expiresAt = new Date(Date.now() + days * DAY_MS).toISOString()
|
||||
const card = { id, title, description, createdAt, expiresAt }
|
||||
await Promise.all([
|
||||
env.REPORTS.put(cardKey(id), JSON.stringify(card), {
|
||||
httpMetadata: { contentType: 'application/json; charset=utf-8' }
|
||||
}),
|
||||
env.REPORTS.put(imageKey(id), image, {
|
||||
httpMetadata: { contentType: 'image/png', cacheControl: 'private, max-age=300' }
|
||||
}),
|
||||
env.REPORTS.put(thumbnailKey(id), thumbnail, {
|
||||
httpMetadata: { contentType: 'image/jpeg', cacheControl: 'public, max-age=300' }
|
||||
})
|
||||
])
|
||||
const origin = publicOrigin(request, env)
|
||||
return json({
|
||||
cardId: id,
|
||||
shareUrl: `${origin}/s/${id}`,
|
||||
viewUrl: `${origin}/v/${id}`,
|
||||
expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
const serveAsset = async (env, id, kind) => {
|
||||
const card = await readCard(env, id)
|
||||
if (!card) return new Response('Not found', { status: 404 })
|
||||
const object = await env.REPORTS.get(kind === 'thumbnail' ? thumbnailKey(id) : imageKey(id))
|
||||
if (!object) return new Response('Not found', { status: 404 })
|
||||
const headers = new Headers()
|
||||
object.writeHttpMetadata(headers)
|
||||
headers.set('x-content-type-options', 'nosniff')
|
||||
headers.set('cache-control', kind === 'thumbnail' ? 'public, max-age=300' : 'private, max-age=60')
|
||||
return new Response(object.body, { headers })
|
||||
}
|
||||
|
||||
const sharePage = (request, env, card) => {
|
||||
const origin = publicOrigin(request, env)
|
||||
const viewUrl = `${origin}/v/${card.id}`
|
||||
const cardLinkUrl = `${origin}/l/${card.id}`
|
||||
const imageUrl = `${origin}/a/${card.id}/thumbnail`
|
||||
const share = { title: card.title, desc: card.description, link: cardLinkUrl, imgUrl: imageUrl }
|
||||
return new Response(
|
||||
`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta property="og:title" content="${escapeHtml(card.title)}">
|
||||
<meta property="og:description" content="${escapeHtml(card.description)}">
|
||||
<meta property="og:image" content="${escapeHtml(imageUrl)}">
|
||||
<title>${escapeHtml(card.title)}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f3f6f5;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
main{min-height:100vh;padding:48px 24px;display:flex;align-items:center;justify-content:center}
|
||||
.card{width:min(100%,440px);background:#fff;border-radius:24px;padding:30px;box-shadow:0 18px 60px rgba(23,32,29,.10);text-align:center}
|
||||
.arrow{font-size:52px;color:#16a66a;transform:rotate(-20deg);margin:-10px 0 12px}
|
||||
h1{font-size:23px;margin:0 0 12px}.desc{color:#61706a;line-height:1.7;margin:0 0 28px}
|
||||
.hint{background:#ecf8f2;border:1px solid #cdebdc;border-radius:16px;padding:18px;line-height:1.7}
|
||||
.open{display:inline-block;margin-top:22px;color:#08794c;text-decoration:none;font-weight:650}
|
||||
#status{font-size:13px;color:#7f8d87;margin-top:18px}
|
||||
</style>
|
||||
</head>
|
||||
<body><main><section class="card">
|
||||
<div class="arrow">↗</div>
|
||||
<h1>点击右上角 ··· 分享</h1>
|
||||
<p class="desc">${escapeHtml(card.description)}</p>
|
||||
<div class="hint">发送给好友或群聊后,将显示为标题、描述和缩略图组成的微信卡片。</div>
|
||||
<a class="open" href="${escapeHtml(viewUrl)}">先查看完整日报</a>
|
||||
<p id="status">正在准备微信分享信息…</p>
|
||||
</section></main>
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||
<script>
|
||||
const share=${safeJson(share)};
|
||||
const status=document.getElementById('status');
|
||||
fetch('/api/wx-signature?url='+encodeURIComponent(location.href.split('#')[0]))
|
||||
.then(r=>r.json().then(data=>({ok:r.ok,data})))
|
||||
.then(({ok,data})=>{
|
||||
if(!ok) throw new Error(data.error||'签名失败');
|
||||
wx.config({...data,debug:false,jsApiList:['updateAppMessageShareData','updateTimelineShareData']});
|
||||
wx.ready(()=>{
|
||||
wx.updateAppMessageShareData({...share,success:()=>status.textContent='分享卡片已准备好'});
|
||||
wx.updateTimelineShareData({title:share.title,link:share.link,imgUrl:share.imgUrl});
|
||||
status.textContent='分享卡片已准备好';
|
||||
});
|
||||
wx.error(err=>{status.textContent='微信分享配置失败:'+(err.errMsg||'未知错误')});
|
||||
})
|
||||
.catch(err=>{status.textContent='微信分享配置失败:'+err.message});
|
||||
</script></body></html>`,
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
'content-security-policy':
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' https://res.wx.qq.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const viewPage = (env, card) => {
|
||||
const origin = String(env.PUBLIC_ORIGIN).replace(/\/+$/, '')
|
||||
return new Response(
|
||||
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(card.title)}</title><style>*{box-sizing:border-box}body{margin:0;background:#eef2f0;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{padding:20px;background:#fff;position:sticky;top:0;box-shadow:0 1px 8px #0001}h1{font-size:18px;margin:0 0 6px}p{margin:0;color:#68746f;font-size:13px}.image{display:block;width:min(100%,900px);height:auto;margin:20px auto;background:#fff}</style></head><body><header><h1>${escapeHtml(card.title)}</h1><p>${escapeHtml(card.description)} · 有效期至 ${escapeHtml(card.expiresAt.slice(0, 10))}</p></header><img class="image" src="${origin}/a/${card.id}/report" alt="${escapeHtml(card.title)}"></body></html>`,
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
'content-security-policy': "default-src 'self'; img-src 'self'; style-src 'unsafe-inline'"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const cachedWechatValue = async (cacheKey, ttl, loader) => {
|
||||
const cache = caches.default
|
||||
const request = new Request(`https://wechat-cache.invalid/${cacheKey}`)
|
||||
const cached = await cache.match(request)
|
||||
if (cached) return cached.json()
|
||||
const value = await loader()
|
||||
await cache.put(request, json(value, { headers: { 'cache-control': `public, max-age=${ttl}` } }))
|
||||
return value
|
||||
}
|
||||
|
||||
const getAccessToken = (env) =>
|
||||
cachedWechatValue(`access-token/${env.WECHAT_APP_ID}`, 6900, async () => {
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/token')
|
||||
url.searchParams.set('grant_type', 'client_credential')
|
||||
url.searchParams.set('appid', env.WECHAT_APP_ID)
|
||||
url.searchParams.set('secret', env.WECHAT_APP_SECRET)
|
||||
const data = await fetch(url).then((response) => response.json())
|
||||
if (!data.access_token) throw new Error(data.errmsg || '无法获取 access_token')
|
||||
return { value: data.access_token }
|
||||
})
|
||||
|
||||
const getTicket = async (env) => {
|
||||
const token = await getAccessToken(env)
|
||||
return cachedWechatValue(`jsapi-ticket/${env.WECHAT_APP_ID}`, 6900, async () => {
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket')
|
||||
url.searchParams.set('access_token', token.value)
|
||||
url.searchParams.set('type', 'jsapi')
|
||||
const data = await fetch(url).then((response) => response.json())
|
||||
if (!data.ticket) throw new Error(data.errmsg || '无法获取 jsapi_ticket')
|
||||
return { value: data.ticket }
|
||||
})
|
||||
}
|
||||
|
||||
const sha1 = async (value) => {
|
||||
const digest = await crypto.subtle.digest('SHA-1', encoder.encode(value))
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
const signature = async (request, env) => {
|
||||
if (!env.WECHAT_APP_ID || !env.WECHAT_APP_SECRET) {
|
||||
return json({ error: '微信 JS-SDK 尚未配置' }, { status: 503 })
|
||||
}
|
||||
const pageUrl = new URL(request.url).searchParams.get('url')
|
||||
if (!pageUrl) return json({ error: '缺少签名 URL' }, { status: 400 })
|
||||
const parsed = new URL(pageUrl)
|
||||
if (parsed.origin !== publicOrigin(request, env)) {
|
||||
return json({ error: '只能签名当前分享域名' }, { status: 400 })
|
||||
}
|
||||
const ticket = await getTicket(env)
|
||||
const nonceStr = crypto.randomUUID().replaceAll('-', '')
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const source = `jsapi_ticket=${ticket.value}&noncestr=${nonceStr}×tamp=${timestamp}&url=${pageUrl}`
|
||||
return json({
|
||||
appId: env.WECHAT_APP_ID,
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature: await sha1(source)
|
||||
})
|
||||
}
|
||||
|
||||
const router = async (request, env) => {
|
||||
const url = new URL(request.url)
|
||||
const verificationContent = WECHAT_DOMAIN_VERIFICATION.get(url.pathname.slice(1))
|
||||
if (request.method === 'GET' && verificationContent) {
|
||||
return new Response(verificationContent, {
|
||||
headers: {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
'x-content-type-options': 'nosniff'
|
||||
}
|
||||
})
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
return json({
|
||||
ok: true,
|
||||
service: 'wechatexplorer-share-card',
|
||||
storage: env.REPORTS ? 'ready' : 'unavailable'
|
||||
})
|
||||
}
|
||||
if (!env.REPORTS && (url.pathname === '/api/cards' || /^\/(s|l|v|a)\//i.test(url.pathname))) {
|
||||
return storageUnavailable()
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/api/cards') return createCard(request, env)
|
||||
if (request.method === 'GET' && url.pathname === '/api/wx-signature') {
|
||||
try {
|
||||
return await signature(request, env)
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
const match = url.pathname.match(/^\/(s|l|v|a)\/([0-9a-f-]{36})(?:\/(report|thumbnail))?$/i)
|
||||
if (!match) return new Response('Not found', { status: 404 })
|
||||
const [, route, id, asset] = match
|
||||
if (route === 'a') return serveAsset(env, id, asset)
|
||||
const card = await readCard(env, id)
|
||||
if (!card) return new Response('卡片不存在或已过期', { status: 404 })
|
||||
if (route === 'l') {
|
||||
return Response.redirect(`${publicOrigin(request, env)}/v/${card.id}`, 302)
|
||||
}
|
||||
return route === 's' ? sharePage(request, env, card) : viewPage(env, card)
|
||||
}
|
||||
|
||||
const cleanup = async (env) => {
|
||||
let cursor
|
||||
do {
|
||||
const listed = await env.REPORTS.list({ prefix: 'cards/', cursor, include: ['httpMetadata'] })
|
||||
const metadataObjects = listed.objects.filter((object) => object.key.endsWith('/card.json'))
|
||||
for (const item of metadataObjects) {
|
||||
const object = await env.REPORTS.get(item.key)
|
||||
if (!object) continue
|
||||
const card = JSON.parse(await object.text())
|
||||
if (Date.parse(card.expiresAt) <= Date.now()) await deleteCard(env, card.id)
|
||||
}
|
||||
cursor = listed.truncated ? listed.cursor : undefined
|
||||
} while (cursor)
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch: (request, env) => router(request, env),
|
||||
scheduled: (_controller, env, ctx) => ctx.waitUntil(cleanup(env))
|
||||
}
|
||||
|
||||
export { escapeHtml, sha1 }
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import worker, { escapeHtml, sha1 } from '../src/index.js'
|
||||
|
||||
class MemoryR2Object {
|
||||
constructor(value, metadata = {}) {
|
||||
this.value = value
|
||||
this.metadata = metadata
|
||||
}
|
||||
|
||||
async text() {
|
||||
return new TextDecoder().decode(this.value)
|
||||
}
|
||||
|
||||
get body() {
|
||||
return this.value
|
||||
}
|
||||
|
||||
writeHttpMetadata(headers) {
|
||||
if (this.metadata.contentType) headers.set('content-type', this.metadata.contentType)
|
||||
if (this.metadata.cacheControl) headers.set('cache-control', this.metadata.cacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryR2 {
|
||||
objects = new Map()
|
||||
|
||||
async put(key, value, options = {}) {
|
||||
const bytes =
|
||||
typeof value === 'string' ? new TextEncoder().encode(value) : new Uint8Array(value)
|
||||
this.objects.set(key, new MemoryR2Object(bytes, options.httpMetadata))
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
return this.objects.get(key) || null
|
||||
}
|
||||
|
||||
async delete(keys) {
|
||||
for (const key of Array.isArray(keys) ? keys : [keys]) this.objects.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const env = () => ({
|
||||
REPORTS: new MemoryR2(),
|
||||
UPLOAD_TOKEN: 'test-upload-token-that-is-long-enough',
|
||||
PUBLIC_ORIGIN: 'https://share.example.com',
|
||||
DEFAULT_EXPIRY_DAYS: '7'
|
||||
})
|
||||
|
||||
test('requires the upload bearer token', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://share.example.com/api/cards', { method: 'POST', body: '{}' }),
|
||||
env()
|
||||
)
|
||||
assert.equal(response.status, 401)
|
||||
})
|
||||
|
||||
test('returns a controlled error when R2 is not configured', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://share.example/s/d9069d5a-d1a0-44fc-a983-2602c3f1cb94'),
|
||||
{ PUBLIC_ORIGIN: 'https://share.example' }
|
||||
)
|
||||
assert.equal(response.status, 503)
|
||||
assert.match(await response.text(), /图片存储服务尚未启用/)
|
||||
})
|
||||
|
||||
test('creates an expiring card and serves only its random assets', async () => {
|
||||
const testEnv = env()
|
||||
const response = await worker.fetch(
|
||||
new Request('https://share.example.com/api/cards', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${testEnv.UPLOAD_TOKEN}`,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '技术交流群日报',
|
||||
description: '今日群聊总结',
|
||||
imageBase64: Buffer.from('png-data').toString('base64'),
|
||||
thumbnailBase64: Buffer.from('jpeg-data').toString('base64')
|
||||
})
|
||||
}),
|
||||
testEnv
|
||||
)
|
||||
assert.equal(response.status, 200)
|
||||
const card = await response.json()
|
||||
assert.match(card.cardId, /^[0-9a-f-]{36}$/)
|
||||
assert.equal(card.shareUrl, `https://share.example.com/s/${card.cardId}`)
|
||||
|
||||
const page = await worker.fetch(new Request(card.shareUrl), testEnv)
|
||||
assert.equal(page.status, 200)
|
||||
const pageHtml = await page.text()
|
||||
assert.match(pageHtml, /updateAppMessageShareData/)
|
||||
assert.match(pageHtml, new RegExp(`/l/${card.cardId}`))
|
||||
|
||||
const link = await worker.fetch(
|
||||
new Request(`https://share.example.com/l/${card.cardId}`),
|
||||
testEnv
|
||||
)
|
||||
assert.equal(link.status, 302)
|
||||
assert.equal(link.headers.get('location'), `https://share.example.com/v/${card.cardId}`)
|
||||
|
||||
const asset = await worker.fetch(
|
||||
new Request(`https://share.example.com/a/${card.cardId}/thumbnail`),
|
||||
testEnv
|
||||
)
|
||||
assert.equal(asset.status, 200)
|
||||
assert.equal(await asset.text(), 'jpeg-data')
|
||||
})
|
||||
|
||||
test('escapes untrusted card metadata and produces the expected SHA-1', async () => {
|
||||
assert.equal(
|
||||
escapeHtml(`<img src=x onerror="alert('x')">&`),
|
||||
'<img src=x onerror="alert('x')">&'
|
||||
)
|
||||
assert.equal(await sha1('abc'), 'a9993e364706816aba3e25717850c26c9cd0d89d')
|
||||
})
|
||||
|
||||
test('removes expired cards when they are requested', async () => {
|
||||
const testEnv = env()
|
||||
const id = '11111111-1111-4111-8111-111111111111'
|
||||
await testEnv.REPORTS.put(
|
||||
`cards/${id}/card.json`,
|
||||
JSON.stringify({
|
||||
id,
|
||||
title: 'expired',
|
||||
description: '',
|
||||
expiresAt: new Date(Date.now() - 1000).toISOString()
|
||||
})
|
||||
)
|
||||
await testEnv.REPORTS.put(`cards/${id}/report.png`, 'image')
|
||||
await testEnv.REPORTS.put(`cards/${id}/thumbnail.jpg`, 'thumb')
|
||||
|
||||
const response = await worker.fetch(new Request(`https://share.example.com/s/${id}`), testEnv)
|
||||
assert.equal(response.status, 404)
|
||||
assert.equal(testEnv.REPORTS.objects.size, 0)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "wechatexplorer-share-card",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-07-23",
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "share.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "REPORTS",
|
||||
"bucket_name": "wechatexplorer-share-reports"
|
||||
}
|
||||
],
|
||||
"triggers": {
|
||||
"crons": ["17 3 * * *"]
|
||||
},
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://share.example.com",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "wechatexplorer-share-card",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-07-23",
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "share.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://share.example.com",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# WechatExplorer WeChat Connector
|
||||
# TraceMemo WeChat Connector
|
||||
|
||||
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
|
||||
This repository-local service provides the minimal WeChat bridge required by TraceMemo:
|
||||
|
||||
- QR-code login with a single persisted credential
|
||||
- account discovery
|
||||
@@ -18,8 +18,8 @@ go run . accounts --json
|
||||
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
|
||||
```
|
||||
|
||||
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
|
||||
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. This legacy directory name is intentionally retained so upgrades can reuse existing accounts. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
|
||||
|
||||
## Attribution
|
||||
|
||||
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
|
||||
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). TraceMemo-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding TraceMemo project.
|
||||
|
||||
@@ -78,13 +78,40 @@ func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status strin
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsDir returns the directory where account credentials are stored.
|
||||
func AccountsDir() (string, error) {
|
||||
func accountsDir(rootName string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
|
||||
return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
|
||||
}
|
||||
|
||||
// AccountsDir returns the TraceMemo directory where new credentials are stored.
|
||||
func AccountsDir() (string, error) {
|
||||
return accountsDir(".tracememo")
|
||||
}
|
||||
|
||||
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
|
||||
func LegacyAccountsDir() (string, error) {
|
||||
return accountsDir(".wechatexplorer")
|
||||
}
|
||||
|
||||
func accountDirectoryForID(accountID string) (string, error) {
|
||||
current, err := AccountsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
legacy, err := LegacyAccountsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
|
||||
return legacy, nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
|
||||
@@ -159,13 +186,7 @@ func SaveCredentials(creds *Credentials) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAllCredentials loads all saved account credentials.
|
||||
func LoadAllCredentials() ([]*Credentials, error) {
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -191,6 +212,24 @@ func LoadAllCredentials() ([]*Credentials, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
|
||||
// untouched WechatExplorer directory for one-version upgrade compatibility.
|
||||
func LoadAllCredentials() ([]*Credentials, error) {
|
||||
current, err := AccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials, err := loadCredentialsFromDir(current)
|
||||
if err != nil || len(credentials) > 0 {
|
||||
return credentials, err
|
||||
}
|
||||
legacy, err := LegacyAccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadCredentialsFromDir(legacy)
|
||||
}
|
||||
|
||||
// CredentialsPath returns the path for display purposes.
|
||||
func CredentialsPath() (string, error) {
|
||||
return AccountsDir()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -34,3 +35,46 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||
t.Fatalf("old sync state still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts")
|
||||
if dir != want {
|
||||
t.Fatalf("AccountsDir() = %q, want %q", dir, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
legacyDir, err := LegacyAccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := LoadAllCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken {
|
||||
t.Fatalf("accounts = %#v", accounts)
|
||||
}
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
t.Fatalf("legacy directory changed or removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,12 @@ type Monitor struct {
|
||||
|
||||
// NewMonitor creates a new long-poll monitor.
|
||||
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
accountID := NormalizeAccountID(client.BotID())
|
||||
accountsRoot, err := accountDirectoryForID(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountID := NormalizeAccountID(client.BotID())
|
||||
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
|
||||
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
|
||||
|
||||
m := &Monitor{
|
||||
client: client,
|
||||
@@ -73,7 +73,7 @@ func (m *Monitor) Run(ctx context.Context) error {
|
||||
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
||||
m.failures, maxConsecutiveFailures, backoff, err)
|
||||
if m.failures == maxConsecutiveFailures {
|
||||
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
|
||||
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures)
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
@@ -96,7 +96,7 @@ func (m *Monitor) Run(ctx context.Context) error {
|
||||
} else {
|
||||
// Sync buf already empty but still getting session expired:
|
||||
// the bot token itself has expired. The user needs to re-login.
|
||||
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
|
||||
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.")
|
||||
}
|
||||
select {
|
||||
case <-time.After(sessionExpiredBackoff):
|
||||
|
||||
@@ -20,6 +20,7 @@ interface TokenReadResult {
|
||||
|
||||
export class ApiTokenStore {
|
||||
private cachedToken: string | null = null
|
||||
private automaticGenerationBlockedReason: string | null = null
|
||||
|
||||
constructor(private readonly filePathOverride?: string) {}
|
||||
|
||||
@@ -42,10 +43,18 @@ export class ApiTokenStore {
|
||||
available: true,
|
||||
hasToken: result.success && Boolean(result.token),
|
||||
maskedToken: MASKED_TOKEN,
|
||||
...(result.success ? {} : { error: result.error })
|
||||
...(result.success
|
||||
? result.token || !this.automaticGenerationBlockedReason
|
||||
? {}
|
||||
: { error: this.automaticGenerationBlockedReason }
|
||||
: { error: result.error })
|
||||
}
|
||||
}
|
||||
|
||||
setAutomaticGenerationBlocked(reason?: string): void {
|
||||
this.automaticGenerationBlockedReason = reason?.trim() || null
|
||||
}
|
||||
|
||||
ensureToken(): ApiTokenActionResult {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
@@ -59,6 +68,9 @@ export class ApiTokenStore {
|
||||
const current = this.read()
|
||||
if (!current.success) return this.actionError(current.error)
|
||||
if (current.token) return this.actionSuccess()
|
||||
if (this.automaticGenerationBlockedReason) {
|
||||
return this.actionError(this.automaticGenerationBlockedReason)
|
||||
}
|
||||
return this.persist(this.generateToken())
|
||||
}
|
||||
|
||||
@@ -70,7 +82,9 @@ export class ApiTokenStore {
|
||||
|
||||
rotateToken(): ApiTokenActionResult {
|
||||
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
|
||||
return this.persist(this.generateToken())
|
||||
const result = this.persist(this.generateToken())
|
||||
if (result.success) this.automaticGenerationBlockedReason = null
|
||||
return result
|
||||
}
|
||||
|
||||
getTokenForAuthentication(): string | null {
|
||||
|
||||
@@ -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,731 @@
|
||||
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'
|
||||
import { SqliteTranscriptRepository } from './voice-pipeline/transcript-repository'
|
||||
|
||||
const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json'
|
||||
const VOICE_TRANSCRIPT_CACHE_ITEM = 'cache/voice-transcripts.sqlite'
|
||||
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'
|
||||
}
|
||||
|
||||
export async function migrateLegacyVoiceTranscripts(
|
||||
sourceRoot: string,
|
||||
targetRoot: string
|
||||
): Promise<MigrationItemStatus> {
|
||||
const relativePath = path.join('cache', 'voice-transcripts.sqlite')
|
||||
const sourcePath = path.join(sourceRoot, relativePath)
|
||||
const targetPath = path.join(targetRoot, relativePath)
|
||||
if (!(await fs.pathExists(sourcePath))) return 'missing'
|
||||
if (path.resolve(sourcePath) === path.resolve(targetPath)) return 'skipped'
|
||||
|
||||
const repository = new SqliteTranscriptRepository(targetPath)
|
||||
try {
|
||||
return repository.mergeFrom(sourcePath) > 0 ? 'migrated' : 'skipped'
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
await runItem(VOICE_TRANSCRIPT_CACHE_ITEM, () =>
|
||||
migrateLegacyVoiceTranscripts(sourceRoot, targetRoot)
|
||||
)
|
||||
|
||||
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.entries(items).some(
|
||||
([name, status]) => name !== VOICE_TRANSCRIPT_CACHE_ITEM && 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)
|
||||
const voiceMigrationStatus = assessment.state?.items[VOICE_TRANSCRIPT_CACHE_ITEM]
|
||||
const needsVoiceBackfill = !voiceMigrationStatus
|
||||
if (
|
||||
assessment.reason === 'migration-completed' &&
|
||||
assessment.sourceRoot &&
|
||||
assessment.state &&
|
||||
needsVoiceBackfill
|
||||
) {
|
||||
const state: MigrationState = {
|
||||
...assessment.state,
|
||||
items: { ...assessment.state.items },
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
try {
|
||||
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = await migrateLegacyVoiceTranscripts(
|
||||
assessment.sourceRoot,
|
||||
roots.current
|
||||
)
|
||||
} catch {
|
||||
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = 'failed'
|
||||
}
|
||||
await writeMigrationState(roots.current, state)
|
||||
return {
|
||||
assessment: { ...assessment, state },
|
||||
action: state.items[VOICE_TRANSCRIPT_CACHE_ITEM] === 'migrated' ? 'migrated' : 'none',
|
||||
execution: {
|
||||
state,
|
||||
tokenGenerationBlocked: false
|
||||
},
|
||||
tokenGenerationBlocked: false
|
||||
}
|
||||
}
|
||||
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,253 @@
|
||||
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',
|
||||
path.join('cache', 'voice-transcripts.sqlite')
|
||||
]
|
||||
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 {
|
||||
return path.join(this.logDir, 'wechatexplorer.log')
|
||||
return path.join(this.logDir, 'tracememo.log')
|
||||
}
|
||||
|
||||
private rotateIfNeeded(): void {
|
||||
|
||||
@@ -995,6 +995,19 @@ const renderExportScript = (name: string): string => `
|
||||
message.contentData && forwardedSearchText(message.contentData.items),
|
||||
message.exportMediaName
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
const exportedImageOrVideoFileStem = (message) => {
|
||||
const mediaType = message.exportMediaType || (message.contentData && message.contentData.type)
|
||||
if (mediaType !== 'image' && mediaType !== 'video') return ''
|
||||
const mediaUrl = String(message.exportMediaUrl || '').split(/[?#]/, 1)[0]
|
||||
const fileName = mediaUrl.slice(mediaUrl.lastIndexOf('/') + 1)
|
||||
if (!fileName) return ''
|
||||
let decodedFileName = fileName
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName)
|
||||
} catch {}
|
||||
const extensionIndex = decodedFileName.lastIndexOf('.')
|
||||
return (extensionIndex > 0 ? decodedFileName.slice(0, extensionIndex) : decodedFileName).toLowerCase()
|
||||
}
|
||||
|
||||
const shareLabel = (typeVal) => {
|
||||
if (String(typeVal) === '5') return '公众号链接'
|
||||
@@ -1516,7 +1529,9 @@ const renderExportScript = (name: string): string => `
|
||||
return allMessages.filter((message) =>
|
||||
(activeConversation === 'all' || message.exportConversationId === activeConversation) &&
|
||||
(activeKind === 'all' || kindOf(message) === activeKind) &&
|
||||
(!term || searchText(message).includes(term))
|
||||
(!term ||
|
||||
searchText(message).includes(term) ||
|
||||
exportedImageOrVideoFileStem(message) === term)
|
||||
)
|
||||
}
|
||||
const applyFilters = (restorePosition = false) => {
|
||||
@@ -1779,7 +1794,7 @@ export function renderExportPage(name: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="query" type="search" placeholder="搜索发送者或消息内容…" aria-label="搜索消息">
|
||||
<input id="query" type="search" placeholder="搜索发送者、消息内容或媒体文件名(不含后缀)…" aria-label="搜索消息">
|
||||
</div>
|
||||
<div class="filters" id="filters">
|
||||
<button class="filter-button active" type="button" data-kind="all">全部</button>
|
||||
|
||||
+530
-84
@@ -21,9 +21,10 @@ import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { getImageExportAttempts } from '../shared/export-media'
|
||||
import { FileAssetService } from './file-asset-service'
|
||||
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
|
||||
import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-cache'
|
||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||
import { imageFileQuality } from '../shared/image-quality'
|
||||
import { resolveMemberName } from '../shared/member-names'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const activeArchives = new Map<string, Archiver>()
|
||||
@@ -43,6 +44,18 @@ const exportStamp = (): string => {
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
const defaultExportRoot = (): string => join(app.getPath('documents'), 'TraceMemo', '导出')
|
||||
const legacyExportRoot = (): string => join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||||
const resolveDefaultExportRoot = async (outputFolder?: string): Promise<string> => {
|
||||
if (!outputFolder) return defaultExportRoot()
|
||||
try {
|
||||
await fs.access(join(legacyExportRoot(), outputFolder))
|
||||
// Continue incremental exports in the legacy folder when it already exists.
|
||||
return legacyExportRoot()
|
||||
} catch {
|
||||
return defaultExportRoot()
|
||||
}
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
|
||||
export interface HtmlExportConversation {
|
||||
@@ -262,6 +275,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
|
||||
if (!current.exportMediaUrl && !current.voiceDataUrl && previous.exportMediaError) {
|
||||
merged.exportMediaError = previous.exportMediaError
|
||||
}
|
||||
if (merged.voiceDataUrl) delete merged.exportMediaError
|
||||
if (merged.voiceTranscript) delete merged.voiceTranscriptError
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -321,7 +336,8 @@ export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] {
|
||||
export async function readHtmlArchive(
|
||||
outputDir: string,
|
||||
targets: ExportTarget[],
|
||||
name: string
|
||||
name: string,
|
||||
allowTargetChanges = false
|
||||
): Promise<HtmlExportArchive> {
|
||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||
let source = ''
|
||||
@@ -383,7 +399,7 @@ export async function readHtmlArchive(
|
||||
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
||||
.map((conversation) => conversation.id)
|
||||
.sort()
|
||||
if (actualIds.join('|') !== expectedIds.join('|')) {
|
||||
if (!allowTargetChanges && actualIds.join('|') !== expectedIds.join('|')) {
|
||||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||||
}
|
||||
return {
|
||||
@@ -463,6 +479,20 @@ export async function writeHtmlArchive(
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const source = `${archiveDataPrefix}${JSON.stringify(archive)};\n`
|
||||
await fs.writeFile(
|
||||
join(dataDir, 'conversations.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: archive.version,
|
||||
name: archive.name,
|
||||
exportedAt: archive.exportedAt,
|
||||
conversations: archive.conversations
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
await fs.writeFile(temporaryPath, source, 'utf8')
|
||||
try {
|
||||
await fs.rename(temporaryPath, dataPath)
|
||||
@@ -624,19 +654,130 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function runExport(
|
||||
interface SingleExportOptions {
|
||||
outputRoot?: string
|
||||
outputFolderName?: string
|
||||
manageJob?: boolean
|
||||
sendProgress?: (progress: ExportJobProgress) => void
|
||||
selfInfo?: CachedSelfInfo | null
|
||||
}
|
||||
|
||||
interface AllExportManifestEntry {
|
||||
id: string
|
||||
name: string
|
||||
type: ExportTarget['type']
|
||||
folder: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
const writeAllExportManifest = async (
|
||||
outputDir: string,
|
||||
conversations: AllExportManifestEntry[],
|
||||
messageCount: number,
|
||||
status: 'running' | 'completed' | 'cancelled' | 'failed',
|
||||
error?: string
|
||||
): Promise<void> => {
|
||||
const manifestPath = join(outputDir, '导出清单.json')
|
||||
const temporaryPath = `${manifestPath}.tmp-${process.pid}-${Date.now()}`
|
||||
await fs.writeFile(
|
||||
temporaryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
status,
|
||||
exportedAt: new Date().toISOString(),
|
||||
messageCount,
|
||||
conversations,
|
||||
error
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
try {
|
||||
await fs.rename(temporaryPath, manifestPath)
|
||||
} catch (error) {
|
||||
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
|
||||
await fs.rm(manifestPath, { force: true })
|
||||
await fs.rename(temporaryPath, manifestPath)
|
||||
} finally {
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const conversationFolderNames = (targets: ExportTarget[]): Map<string, string> => {
|
||||
const names = new Map<string, string>()
|
||||
const used = new Set<string>()
|
||||
for (const target of targets) {
|
||||
const base = safeFilePart(target.name).slice(0, 80).trim() || '未命名聊天'
|
||||
let candidate = base
|
||||
const usedKey = (value: string): string => `${target.type}:${value.toLowerCase()}`
|
||||
if (used.has(usedKey(candidate))) {
|
||||
candidate = `${base}_${hashPart(target.userMd5, 8)}`
|
||||
}
|
||||
let suffix = 2
|
||||
while (used.has(usedKey(candidate))) {
|
||||
candidate = `${base}_${hashPart(target.userMd5, 8)}_${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
used.add(usedKey(candidate))
|
||||
names.set(target.userMd5, candidate)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
const pathExists = async (value: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.stat(value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const preserveLegacyCombinedArchive = async (outputDir: string): Promise<void> => {
|
||||
const legacyIndex = join(outputDir, 'index.html')
|
||||
const legacyData = join(outputDir, 'data', 'messages.js')
|
||||
if (!(await pathExists(legacyIndex)) || !(await pathExists(legacyData))) return
|
||||
|
||||
let legacyDir = join(outputDir, '旧版合并档案')
|
||||
if (await pathExists(legacyDir)) {
|
||||
legacyDir = join(outputDir, `旧版合并档案_${exportStamp()}`)
|
||||
}
|
||||
await fs.mkdir(legacyDir, { recursive: true })
|
||||
for (const entry of ['index.html', 'data', 'avatars', 'media', 'voices', 'files']) {
|
||||
const source = join(outputDir, entry)
|
||||
if (await pathExists(source)) await fs.rename(source, join(legacyDir, entry))
|
||||
}
|
||||
}
|
||||
|
||||
async function runSingleExport(
|
||||
request: ExportRequest,
|
||||
win: BrowserWindow,
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
|
||||
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>,
|
||||
options: SingleExportOptions = {}
|
||||
): Promise<ExportResult> {
|
||||
jobs.add(request.jobId)
|
||||
const send = (p: ExportJobProgress): void => {
|
||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||
}
|
||||
const manageJob = options.manageJob !== false
|
||||
if (manageJob) jobs.add(request.jobId)
|
||||
const send =
|
||||
options.sendProgress ||
|
||||
((p: ExportJobProgress): void => {
|
||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||
})
|
||||
try {
|
||||
const targets = request.targets || []
|
||||
if (targets.length < 1 || targets.length > 5) {
|
||||
throw new Error('一次导出必须选择 1 到 5 个聊天')
|
||||
const targets = (request.targets || []).map((target) => ({
|
||||
...target,
|
||||
nameMap: { ...(target.nameMap || {}) },
|
||||
avatarUrls: { ...(target.avatarUrls || {}) }
|
||||
}))
|
||||
const targetLimit = request.scope === 'all' ? null : 5
|
||||
if (targets.length < 1 || (targetLimit !== null && targets.length > targetLimit)) {
|
||||
throw new Error(
|
||||
targetLimit === null ? '全部导出至少需要一个聊天' : '一次导出必须选择 1 到 5 个聊天'
|
||||
)
|
||||
}
|
||||
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
|
||||
throw new Error('导出聊天不能重复')
|
||||
@@ -654,6 +795,25 @@ export async function runExport(
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
if (
|
||||
target.type === 'group' &&
|
||||
(request.scope === 'all' || !Object.keys(target.nameMap || {}).length)
|
||||
) {
|
||||
const snapshot = await chat.getGroupSnapshotAsync(target.userMd5)
|
||||
for (const member of snapshot?.members || []) {
|
||||
target.nameMap![member.wxid] = resolveMemberName(
|
||||
{
|
||||
wxid: member.wxid,
|
||||
nickname: member.nickname,
|
||||
groupNickname: member.groupNickname,
|
||||
wechatNickname: member.wechatNickname,
|
||||
remark: member.remark
|
||||
},
|
||||
target.nameMode || 'groupNickname'
|
||||
)
|
||||
if (member.avatar) target.avatarUrls![member.wxid] = member.avatar
|
||||
}
|
||||
}
|
||||
const targetMessages = (
|
||||
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
|
||||
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||
@@ -684,8 +844,12 @@ export async function runExport(
|
||||
return left.messageOrder - right.messageOrder
|
||||
})
|
||||
.map((entry) => entry.message)
|
||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
||||
const selfInfo =
|
||||
options.selfInfo !== undefined
|
||||
? options.selfInfo
|
||||
: await chat
|
||||
.getSelfAccountInfoAsync()
|
||||
.then((value) => (value ? mergeCachedSelfInfo(value.accountRoot, value) : null))
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
const isUsableSelfName = (value: string | undefined): value is string => {
|
||||
const name = String(value || '').trim()
|
||||
@@ -759,25 +923,37 @@ export async function runExport(
|
||||
total: messages.length,
|
||||
percent: request.format === 'html' ? 18 : 20
|
||||
})
|
||||
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const ext = request.format === 'markdown' ? 'md' : request.format
|
||||
const outputFolder =
|
||||
request.format === 'html'
|
||||
? safeFilePart(request.outputName)
|
||||
? options.outputFolderName || safeFilePart(request.outputName)
|
||||
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||
const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const outputDir = join(root, outputFolder)
|
||||
const outputPath =
|
||||
request.format === 'html'
|
||||
? join(outputDir, 'index.html')
|
||||
: join(root, `${outputFolder}.${ext}`)
|
||||
if (request.format === 'html') {
|
||||
const previousArchive = await readHtmlArchive(outputDir, targets, archiveName)
|
||||
const previousArchive = await readHtmlArchive(
|
||||
outputDir,
|
||||
targets,
|
||||
archiveName,
|
||||
request.scope === 'all'
|
||||
)
|
||||
const currentTargetIds = new Set(targets.map((target) => target.userMd5))
|
||||
const previousMessages =
|
||||
request.scope === 'all'
|
||||
? previousArchive.messages.filter((message) =>
|
||||
currentTargetIds.has(message.exportConversationId || targets[0].userMd5)
|
||||
)
|
||||
: previousArchive.messages
|
||||
const previousMessagesByKey = new Map(
|
||||
previousArchive.messages.map((message) => [exportMessageKey(message), message])
|
||||
previousMessages.map((message) => [exportMessageKey(message), message])
|
||||
)
|
||||
const latestPreviousAvatarUrls = new Map<string, string>()
|
||||
for (const message of previousArchive.messages) {
|
||||
for (const message of previousMessages) {
|
||||
if (!message.exportAvatarUrl) continue
|
||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||
latestPreviousAvatarUrls.set(
|
||||
@@ -910,89 +1086,173 @@ export async function runExport(
|
||||
total: voiceMessages.length,
|
||||
percent: 20
|
||||
})
|
||||
for (const [voiceIndex, message] of voiceMessages.entries()) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const previous = reusablePreviousMessages.get(message)
|
||||
let canTranscribe = true
|
||||
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
|
||||
message.voiceDataUrl = previous.voiceDataUrl
|
||||
message.voiceDuration = previous.voiceDuration
|
||||
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
|
||||
message.voiceTranscript = previous.voiceTranscript
|
||||
const voiceIndexUpdates = new Map<
|
||||
string,
|
||||
{
|
||||
reference: {
|
||||
sessionId: string
|
||||
localId: number
|
||||
createTime: number
|
||||
svrId?: string | number
|
||||
}
|
||||
} else if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
canTranscribe = false
|
||||
} else {
|
||||
try {
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) {
|
||||
const detail = voice.error || '未知原因'
|
||||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||||
? `语音文件缺失:${detail}`
|
||||
: /Silk|解码|数据为空/.test(detail)
|
||||
? `语音解析失败:${detail}`
|
||||
: `语音格式不支持或读取失败:${detail}`
|
||||
keepMediaError(request, message, reason)
|
||||
canTranscribe = false
|
||||
} else {
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
|
||||
const voiceUrl = `voices/${voiceName}`
|
||||
if (!(await resourceExists(voiceUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
markResourceExists(voiceUrl)
|
||||
transcript: string
|
||||
cached: boolean
|
||||
}
|
||||
>()
|
||||
const batchSize = 16
|
||||
for (let batchStart = 0; batchStart < voiceMessages.length; batchStart += batchSize) {
|
||||
const batch = voiceMessages.slice(batchStart, batchStart + batchSize)
|
||||
const mediaItems: Array<{
|
||||
message: Message
|
||||
reference: {
|
||||
sessionId: string
|
||||
localId: number
|
||||
createTime: number
|
||||
svrId?: string | number
|
||||
}
|
||||
}> = []
|
||||
for (const message of batch) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const previous = reusablePreviousMessages.get(message)
|
||||
const hasVoiceIdentity = Boolean(
|
||||
message.sessionId && message.localId != null && message.createTime
|
||||
)
|
||||
const reference = hasVoiceIdentity
|
||||
? {
|
||||
sessionId: message.sessionId!,
|
||||
localId: message.localId!,
|
||||
createTime: message.createTime!,
|
||||
svrId: message.serverId
|
||||
}
|
||||
message.voiceDataUrl = voiceUrl
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
: null
|
||||
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
|
||||
message.voiceDataUrl = previous.voiceDataUrl
|
||||
message.voiceDuration = previous.voiceDuration
|
||||
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
|
||||
message.voiceTranscript = previous.voiceTranscript
|
||||
}
|
||||
}
|
||||
if (request.includeVoiceTranscripts && !message.voiceTranscript) {
|
||||
if (!reference) {
|
||||
message.voiceTranscriptError = '语音标识不完整,无法转文字'
|
||||
} else if (!voiceRecognition) {
|
||||
message.voiceTranscriptError = '语音转文字服务不可用'
|
||||
} else {
|
||||
try {
|
||||
const recognition = await voiceRecognition.recognize(
|
||||
reference,
|
||||
voiceRecognition.publishTranscript
|
||||
? { publishTranscriptUpdate: false }
|
||||
: undefined
|
||||
)
|
||||
if (recognition.success) {
|
||||
message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字'
|
||||
if (voiceRecognition.publishTranscript && recognition.transcript?.trim()) {
|
||||
voiceIndexUpdates.set(reference.sessionId, {
|
||||
reference,
|
||||
transcript: recognition.transcript.trim(),
|
||||
cached: Boolean(recognition.cached)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
message.voiceTranscriptError = recognition.error || '语音识别失败'
|
||||
}
|
||||
} catch (error) {
|
||||
message.voiceTranscriptError =
|
||||
error instanceof Error ? error.message : '语音识别失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
request.includeVoiceTranscripts &&
|
||||
reference &&
|
||||
message.voiceTranscript &&
|
||||
voiceRecognition?.publishTranscript &&
|
||||
!voiceIndexUpdates.has(reference.sessionId)
|
||||
) {
|
||||
voiceIndexUpdates.set(reference.sessionId, {
|
||||
reference,
|
||||
transcript: message.voiceTranscript,
|
||||
cached: true
|
||||
})
|
||||
}
|
||||
if (!message.voiceDataUrl) {
|
||||
if (!reference) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
} else {
|
||||
mediaItems.push({ message, reference })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const voices =
|
||||
typeof voiceService.resolveVoices === 'function'
|
||||
? await voiceService.resolveVoices(mediaItems.map((item) => item.reference))
|
||||
: await Promise.all(
|
||||
mediaItems.map(({ reference }) =>
|
||||
voiceService.resolveVoice(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime,
|
||||
reference.svrId
|
||||
)
|
||||
)
|
||||
)
|
||||
for (const [{ message }, voice] of mediaItems.map(
|
||||
(item, index) => [item, voices[index]] as const
|
||||
)) {
|
||||
if (!voice?.success || !voice.data) {
|
||||
const detail = voice?.error || '未知原因'
|
||||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||||
? `语音文件缺失:${detail}`
|
||||
: /Silk|解码|数据为空/.test(detail)
|
||||
? `语音解析失败:${detail}`
|
||||
: `语音格式不支持或读取失败:${detail}`
|
||||
keepMediaError(request, message, reason)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
|
||||
const voiceUrl = `voices/${voiceName}`
|
||||
if (!(await resourceExists(voiceUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
markResourceExists(voiceUrl)
|
||||
}
|
||||
message.voiceDataUrl = voiceUrl
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
canTranscribe = false
|
||||
}
|
||||
}
|
||||
if (request.includeVoiceTranscripts && canTranscribe && !message.voiceTranscript) {
|
||||
if (!voiceRecognition) {
|
||||
message.voiceTranscriptError = '语音转文字服务不可用'
|
||||
} else {
|
||||
try {
|
||||
const recognition = await voiceRecognition.recognize({
|
||||
sessionId: message.sessionId!,
|
||||
localId: message.localId!,
|
||||
createTime: message.createTime!,
|
||||
svrId: message.serverId
|
||||
})
|
||||
if (recognition.success) {
|
||||
message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字'
|
||||
} else {
|
||||
message.voiceTranscriptError = recognition.error || '语音识别失败'
|
||||
}
|
||||
} catch (error) {
|
||||
message.voiceTranscriptError =
|
||||
error instanceof Error ? error.message : '语音识别失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
const processedVoices = Math.min(batchStart + batch.length, voiceMessages.length)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: voicePhase,
|
||||
processed: voiceIndex + 1,
|
||||
processed: processedVoices,
|
||||
total: voiceMessages.length,
|
||||
percent:
|
||||
20 +
|
||||
Math.round(
|
||||
((voiceIndex + 1) / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
|
||||
(processedVoices / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
|
||||
)
|
||||
})
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
for (const update of voiceIndexUpdates.values()) {
|
||||
try {
|
||||
await voiceRecognition?.publishTranscript?.(
|
||||
update.reference,
|
||||
update.transcript,
|
||||
update.cached
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[Export] voice transcript index refresh failed:', error)
|
||||
}
|
||||
}
|
||||
} else if (request.includeMedia) {
|
||||
for (const message of messages) {
|
||||
@@ -1232,7 +1492,7 @@ export async function runExport(
|
||||
})
|
||||
}
|
||||
const mergedMessages = mergeHtmlArchiveMessages(
|
||||
previousArchive.messages,
|
||||
previousMessages,
|
||||
messages,
|
||||
'',
|
||||
targets.map((target) => target.userMd5)
|
||||
@@ -1319,10 +1579,196 @@ export async function runExport(
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
if (manageJob) jobs.delete(request.jobId)
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllExport(
|
||||
request: ExportRequest,
|
||||
win: BrowserWindow,
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
|
||||
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
|
||||
): 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 = request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
|
||||
outputDir = join(exportRoot, outputFolder)
|
||||
const folderNames = conversationFolderNames(targets)
|
||||
let lastProgressAt = 0
|
||||
let lastProgressKey = ''
|
||||
await fs.mkdir(outputDir, { recursive: true })
|
||||
await preserveLegacyCombinedArchive(outputDir)
|
||||
const selectedTypes = request.allContactTypes?.length
|
||||
? request.allContactTypes
|
||||
: Array.from(new Set(targets.map((target) => target.type)))
|
||||
for (const type of selectedTypes) {
|
||||
await fs.mkdir(join(outputDir, type === 'group' ? '群聊' : '联系人'), { recursive: true })
|
||||
}
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
||||
|
||||
for (const [targetIndex, target] of targets.entries()) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
||||
const categoryDir = join(outputDir, categoryName)
|
||||
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
||||
await fs.mkdir(categoryDir, { recursive: true })
|
||||
const conversationOutputRoot =
|
||||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||||
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'reading',
|
||||
processed: 0,
|
||||
percent: basePercent,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
|
||||
const result = await runSingleExport(
|
||||
{
|
||||
...request,
|
||||
targets: [target],
|
||||
outputName: target.name,
|
||||
zip: false
|
||||
},
|
||||
win,
|
||||
voiceRecognition,
|
||||
{
|
||||
outputRoot: conversationOutputRoot,
|
||||
outputFolderName: request.format === 'html' ? folderName : undefined,
|
||||
manageJob: false,
|
||||
selfInfo,
|
||||
sendProgress: (childProgress) => {
|
||||
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
||||
const percent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
||||
)
|
||||
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
||||
const now = Date.now()
|
||||
const progressKey = `${targetIndex}:${phase}:${percent}`
|
||||
const terminal = phase === 'failed' || phase === 'cancelled'
|
||||
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
||||
lastProgressKey = progressKey
|
||||
lastProgressAt = now
|
||||
send({
|
||||
...childProgress,
|
||||
jobId: request.jobId,
|
||||
phase,
|
||||
percent,
|
||||
outputPath: undefined,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!result.success) {
|
||||
if (result.error === '已取消') throw new Error('已取消')
|
||||
throw new Error(`${target.name}:${result.error || '导出失败'}`)
|
||||
}
|
||||
|
||||
const messageCount = result.messageCount || 0
|
||||
totalMessages += messageCount
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder: `${categoryName}/${folderName}`,
|
||||
messageCount
|
||||
})
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||||
}
|
||||
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
|
||||
|
||||
let completedPath = outputDir
|
||||
if (request.zip) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const zipPath = join(exportRoot, `${outputFolder}.zip`)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'compressing',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: 99
|
||||
})
|
||||
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
|
||||
completedPath = zipPath
|
||||
}
|
||||
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: 100,
|
||||
outputPath: completedPath,
|
||||
currentTargetIndex: targets.length,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: targets.at(-1)?.name,
|
||||
currentTargetType: targets.at(-1)?.type
|
||||
})
|
||||
return { success: true, outputPath: completedPath, messageCount: totalMessages }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
||||
if (outputDir) {
|
||||
try {
|
||||
await writeAllExportManifest(
|
||||
outputDir,
|
||||
manifest,
|
||||
totalMessages,
|
||||
cancelled ? 'cancelled' : 'failed',
|
||||
cancelled ? undefined : message
|
||||
)
|
||||
} catch (manifestError) {
|
||||
console.warn('[Export] failed to update all-export manifest:', manifestError)
|
||||
}
|
||||
}
|
||||
if (cancelled) {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, error: '已取消' })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
jobs.delete(request.jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runExport(
|
||||
request: ExportRequest,
|
||||
win: BrowserWindow,
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
|
||||
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
|
||||
): Promise<ExportResult> {
|
||||
return request.scope === 'all'
|
||||
? runAllExport(request, win, voiceRecognition)
|
||||
: runSingleExport(request, win, voiceRecognition)
|
||||
}
|
||||
export function cancelExport(jobId: string): void {
|
||||
jobs.delete(jobId)
|
||||
activeArchives.get(jobId)?.abort()
|
||||
|
||||
@@ -6,20 +6,23 @@ import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
GroupReportMetadata,
|
||||
GroupReportRenderSnapshot,
|
||||
GroupReportRenderSnapshotExportRequest,
|
||||
ReportHeat,
|
||||
ReportSectionMeta
|
||||
ReportSectionMeta,
|
||||
selectHeroParticipantNames
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
import { getReportTemplate } from '../shared/report-templates'
|
||||
|
||||
const TEMPLATE_FILES: Record<string, string> = {
|
||||
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
||||
v1: 'mobile_daily_report_v1.html',
|
||||
v2: 'mobile_daily_report_v2.html'
|
||||
}
|
||||
const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
|
||||
|
||||
const templatePath = (templateId?: string): string => {
|
||||
const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE
|
||||
const name = LEGACY_TEMPLATE_FILES[templateId || ''] || getReportTemplate(templateId).resourceFile
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', name),
|
||||
path.join(app.getAppPath(), 'resources', name),
|
||||
@@ -74,7 +77,7 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 WechatExplorer',
|
||||
'User-Agent': 'Mozilla/5.0 TraceMemo',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
@@ -167,6 +170,7 @@ const overflowNote = (
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const template = getReportTemplate(request.templateId)
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||
@@ -174,7 +178,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||
)
|
||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||
|
||||
@@ -186,11 +189,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||
|
||||
const heroNames = metadata.heroParticipants.slice(0, 4)
|
||||
while (heroNames.length < 4) heroNames.push(metadata.groupName)
|
||||
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||
.join('')
|
||||
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
||||
|
||||
const topicCards = report.topics
|
||||
.map(
|
||||
@@ -218,10 +221,18 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
if (insight) {
|
||||
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
|
||||
// 走 ImageDecryptService.findImageFile + decryptImageToBase64
|
||||
const decryptService = (globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null; decryptImageToBase64: (p: string) => string | null } }).__imageDecrypt
|
||||
const decryptService = (
|
||||
globalThis as {
|
||||
__imageDecrypt?: {
|
||||
findImageFile: (md5?: string, dat?: string) => string | null
|
||||
decryptImageToBase64: (p: string) => string | null
|
||||
}
|
||||
}
|
||||
).__imageDecrypt
|
||||
if (decryptService) {
|
||||
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
|
||||
if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
|
||||
if (filePath)
|
||||
imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,20 +343,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
const galleryCards = (report.media?.gallery || [])
|
||||
.map(
|
||||
(item) => `<div class="gallery-card">
|
||||
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
|
||||
<div class="gallery-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
|
||||
<div class="important-text">${escapeHtml(item.note)}</div>
|
||||
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
// AI 图片理解结果板块(ImageInsight)
|
||||
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
|
||||
const visionCards = (report.media?.visionGallery || [])
|
||||
@@ -402,11 +399,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
.join('')
|
||||
|
||||
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
|
||||
const heatBarsHtml = report.analytics.topSpeakers
|
||||
.slice(0, 8)
|
||||
const heatSpeakers = report.analytics.topSpeakers.slice(0, 8)
|
||||
const maxSpeakerCount = Math.max(1, ...heatSpeakers.map((speaker) => Math.max(0, speaker.count)))
|
||||
const heatBarsHtml = heatSpeakers
|
||||
.map((speaker) => {
|
||||
const count = Math.max(0, speaker.count)
|
||||
const width = Math.min(100, count * 12)
|
||||
const width = Math.max(4, Math.round((count / maxSpeakerCount) * 100))
|
||||
return `<div class="heat-row">
|
||||
<span class="heat-name">${escapeHtml(speaker.name)}</span>
|
||||
<span class="heat-bar"><i style="width:${width}%"></i></span>
|
||||
@@ -445,13 +443,19 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const values: Record<string, string> = {
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
TEMPLATE_LABEL: escapeHtml(template.label),
|
||||
TEMPLATE_NAME: escapeHtml(template.name),
|
||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||
REPORT_DATE: escapeHtml(metadata.reportDate),
|
||||
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
|
||||
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
|
||||
OVERVIEW: escapeHtml(
|
||||
report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'
|
||||
),
|
||||
// v2 模板使用的 hero-*
|
||||
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
||||
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
|
||||
@@ -462,6 +466,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
|
||||
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
|
||||
HERO_AVATARS: heroAvatars,
|
||||
HERO_AVATAR_CLASS: heroAvatarClass,
|
||||
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||
@@ -473,13 +478,21 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'importantMessages',
|
||||
report.importantMessages.length > 0
|
||||
),
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
||||
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
|
||||
ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
|
||||
ACTIONS_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'actions',
|
||||
report.todos.length + report.unresolved.length > 0
|
||||
),
|
||||
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||
TODO_CARDS: todoCards,
|
||||
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
|
||||
@@ -505,13 +518,14 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
),
|
||||
VISION_CARDS: visionCards,
|
||||
VISION_TITLE: '📸 AI 识别的图片精选',
|
||||
GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
|
||||
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
|
||||
VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
|
||||
VOICE_RANK_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'voices',
|
||||
report.analytics.voiceLeaderboard?.length > 0
|
||||
),
|
||||
VOICE_RANK_CARDS: voiceRankCards,
|
||||
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
|
||||
BADGE_CARDS: badgeCards,
|
||||
@@ -536,11 +550,242 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
return html
|
||||
}
|
||||
|
||||
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||
const renderReportSnapshotHtml = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(request.templateId)
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const values = {
|
||||
...request.snapshot.values,
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
TEMPLATE_LABEL: escapeHtml(template.label),
|
||||
TEMPLATE_NAME: escapeHtml(template.name),
|
||||
REPORT_TITLE:
|
||||
request.snapshot.values.REPORT_TITLE || escapeHtml(`${request.snapshot.groupName}日报`),
|
||||
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
return html.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
|
||||
}
|
||||
|
||||
export const extractGroupReportRenderSnapshot = async (
|
||||
htmlPath: string,
|
||||
fallback: {
|
||||
groupName: string
|
||||
reportDate: string
|
||||
dateRange: string
|
||||
messageCount: number
|
||||
generatedAt: string
|
||||
}
|
||||
): Promise<GroupReportRenderSnapshot> => {
|
||||
let reportWindow: BrowserWindow | null = null
|
||||
try {
|
||||
reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
const extracted = (await reportWindow.webContents.executeJavaScript(`(() => {
|
||||
const one = (selector) => document.querySelector(selector)
|
||||
const text = (...selectors) => {
|
||||
for (const selector of selectors) {
|
||||
const value = one(selector)?.textContent?.trim()
|
||||
if (value) return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const html = (...selectors) => {
|
||||
for (const selector of selectors) {
|
||||
const value = one(selector)?.innerHTML
|
||||
if (value?.trim()) return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const sectionChildren = (selector) => {
|
||||
const section = one(selector)
|
||||
if (!section) return ''
|
||||
return Array.from(section.children)
|
||||
.filter((child) => !child.classList.contains('section-title'))
|
||||
.map((child) => child.outerHTML)
|
||||
.join('')
|
||||
}
|
||||
const sectionClass = (...selectors) => {
|
||||
for (const selector of selectors) {
|
||||
const section = one(selector)
|
||||
if (!section) continue
|
||||
return section.classList.contains('empty-section') || !sectionChildren(selector).trim()
|
||||
? 'empty-section'
|
||||
: ''
|
||||
}
|
||||
return 'empty-section'
|
||||
}
|
||||
const statValues = Array.from(document.querySelectorAll('.hero .stat b, .report-stats .stat-block strong'))
|
||||
.map((node) => node.textContent?.trim() || '')
|
||||
const activity = Array.from(document.querySelectorAll('.analytics > .card, .section-analytics-heat .card'))
|
||||
.find((node) => node.textContent?.includes('活跃时间线'))
|
||||
?.textContent?.replace(/^.*?活跃时间线[::]?/, '')
|
||||
.trim() || ''
|
||||
const legacyRanks = Array.from(document.querySelectorAll('.analytics .rank'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join('')
|
||||
const legacyHeat = Array.from(document.querySelectorAll('.analytics > .heat-row'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join('')
|
||||
const footerText = text('.footer', '.report-footer')
|
||||
.replaceAll('\\n', ' ')
|
||||
.replaceAll('\\r', ' ')
|
||||
.replaceAll('\\t', ' ')
|
||||
return {
|
||||
reportTitle: text('.hero h1', '.report-masthead h1', 'title'),
|
||||
reportDate: text('.report-date strong'),
|
||||
overview: text('.overview', '.report-lede'),
|
||||
recordNote: text('.record-note'),
|
||||
heroAvatars: html('.avatar-grid', '.report-hero-avatars'),
|
||||
messageCount: statValues[0] || '',
|
||||
activeUsers: statValues[1] || '',
|
||||
timeSpan: statValues[2] || '',
|
||||
topicCount: statValues[3] || '',
|
||||
topicCards: sectionChildren('.topics') || html('.section-topics .topics-grid'),
|
||||
importantMessages: sectionChildren('.messages') || html('.section-messages .section-body'),
|
||||
quoteBlocks: sectionChildren('.quotes') || html('.section-quotes .section-body'),
|
||||
qaCards: sectionChildren('.qa') || html('.section-qa .section-body'),
|
||||
resourceItems: sectionChildren('.resources') || html('.section-resources .section-body'),
|
||||
visionCards: sectionChildren('.vision') || html('.section-vision .vision-grid'),
|
||||
rankItems: legacyRanks || html('.section-analytics-rank .rank-list'),
|
||||
heatBars: legacyHeat || html('.section-analytics-heat .section-body'),
|
||||
activityTimeline: activity,
|
||||
cloudTags: html('.cloud-tags', '.section-keywords .cloud-tags'),
|
||||
footerText,
|
||||
classes: {
|
||||
topics: sectionClass('.topics', '.section-topics'),
|
||||
messages: sectionClass('.messages', '.section-messages'),
|
||||
quotes: sectionClass('.quotes', '.section-quotes'),
|
||||
qa: sectionClass('.qa', '.section-qa'),
|
||||
resources: sectionClass('.resources', '.section-resources'),
|
||||
vision: sectionClass('.vision', '.section-vision'),
|
||||
keywords: sectionClass('.cloud', '.section-keywords')
|
||||
}
|
||||
}
|
||||
})()`)) as {
|
||||
reportTitle: string
|
||||
reportDate: string
|
||||
overview: string
|
||||
recordNote: string
|
||||
heroAvatars: string
|
||||
messageCount: string
|
||||
activeUsers: string
|
||||
timeSpan: string
|
||||
topicCount: string
|
||||
topicCards: string
|
||||
importantMessages: string
|
||||
quoteBlocks: string
|
||||
qaCards: string
|
||||
resourceItems: string
|
||||
visionCards: string
|
||||
rankItems: string
|
||||
heatBars: string
|
||||
activityTimeline: string
|
||||
cloudTags: string
|
||||
footerText: string
|
||||
classes: Record<string, string>
|
||||
}
|
||||
if (!extracted.reportTitle || !extracted.topicCards) {
|
||||
throw new Error('旧日报 HTML 缺少可迁移的标题或主题内容')
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {
|
||||
REPORT_TITLE: escapeHtml(extracted.reportTitle),
|
||||
REPORT_DATE: escapeHtml(extracted.reportDate || fallback.reportDate),
|
||||
DATE_RANGE: escapeHtml(fallback.dateRange),
|
||||
TIME_SPAN: escapeHtml(extracted.timeSpan),
|
||||
HERO_SUMMARY: escapeHtml(extracted.overview),
|
||||
HERO_TAKEAWAY: '',
|
||||
HERO_PENDING: '',
|
||||
HERO_STATUS_LINE: '',
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section',
|
||||
HERO_PENDING_EMPTY_CLASS: 'empty-section',
|
||||
HERO_STATUS_EMPTY_CLASS: 'empty-section',
|
||||
HERO_AVATARS: extracted.heroAvatars,
|
||||
HERO_AVATAR_CLASS: extracted.heroAvatars ? '' : 'empty-section',
|
||||
MESSAGE_COUNT: escapeHtml(extracted.messageCount || String(fallback.messageCount)),
|
||||
ACTIVE_USERS: escapeHtml(extracted.activeUsers),
|
||||
TOPIC_COUNT: escapeHtml(extracted.topicCount),
|
||||
RECORD_NOTE: escapeHtml(extracted.recordNote),
|
||||
GENERATED_AT: escapeHtml(fallback.generatedAt),
|
||||
FOOTER_NOTE: escapeHtml(extracted.footerText),
|
||||
TOPIC_CARDS: extracted.topicCards,
|
||||
IMPORTANT_MESSAGES: extracted.importantMessages,
|
||||
QUOTE_BLOCKS: extracted.quoteBlocks,
|
||||
QA_CARDS: extracted.qaCards,
|
||||
RESOURCE_ITEMS: extracted.resourceItems,
|
||||
VISION_TITLE: '📸 AI 识别的图片精选',
|
||||
VISION_CARDS: extracted.visionCards,
|
||||
RANK_ITEMS: extracted.rankItems,
|
||||
HEAT_BARS: extracted.heatBars,
|
||||
ACTIVITY_TIMELINE: escapeHtml(extracted.activityTimeline),
|
||||
CLOUD_TAGS: extracted.cloudTags,
|
||||
TOPICS_EMPTY_CLASS: extracted.classes.topics,
|
||||
MESSAGES_EMPTY_CLASS: extracted.classes.messages,
|
||||
QUOTES_EMPTY_CLASS: extracted.classes.quotes,
|
||||
QA_EMPTY_CLASS: extracted.classes.qa,
|
||||
RESOURCES_EMPTY_CLASS: extracted.classes.resources,
|
||||
VISION_EMPTY_CLASS: extracted.classes.vision,
|
||||
KEYWORDS_EMPTY_CLASS: extracted.classes.keywords,
|
||||
ANALYTICS_EMPTY_CLASS: extracted.heatBars || extracted.rankItems ? '' : 'empty-section',
|
||||
ACTIONS_EMPTY_CLASS: 'empty-section',
|
||||
STORYLINES_EMPTY_CLASS: 'empty-section',
|
||||
REVERSALS_EMPTY_CLASS: 'empty-section',
|
||||
CHAINS_EMPTY_CLASS: 'empty-section',
|
||||
VOICE_EMPTY_CLASS: 'empty-section',
|
||||
VOICE_RANK_EMPTY_CLASS: 'empty-section',
|
||||
BADGES_EMPTY_CLASS: 'empty-section',
|
||||
TODO_CARDS: '',
|
||||
UNRESOLVED_CARDS: '',
|
||||
STORYLINE_CARDS: '',
|
||||
REVERSAL_CARDS: '',
|
||||
CHAIN_CARDS: '',
|
||||
VOICE_CARDS: '',
|
||||
VOICE_RANK_CARDS: '',
|
||||
BADGE_CARDS: '',
|
||||
TOPICS_MORE_NOTE: '',
|
||||
MESSAGES_MORE_NOTE: '',
|
||||
QUOTES_MORE_NOTE: '',
|
||||
QA_MORE_NOTE: '',
|
||||
RESOURCES_MORE_NOTE: '',
|
||||
ACTIONS_MORE_NOTE: '',
|
||||
STORYLINES_MORE_NOTE: '',
|
||||
REVERSALS_MORE_NOTE: '',
|
||||
CHAINS_MORE_NOTE: '',
|
||||
VOICE_MORE_NOTE: '',
|
||||
BADGES_MORE_NOTE: '',
|
||||
KEYWORDS_MORE_NOTE: ''
|
||||
}
|
||||
return {
|
||||
groupName: fallback.groupName,
|
||||
reportDate: extracted.reportDate || fallback.reportDate,
|
||||
values
|
||||
}
|
||||
} finally {
|
||||
if (reportWindow && !reportWindow.isDestroyed()) reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
const captureFullPage = async (
|
||||
htmlPath: string,
|
||||
pngPath: string,
|
||||
templateId?: string
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(templateId)
|
||||
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
|
||||
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
|
||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
width: captureWidth,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
@@ -559,10 +804,10 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
|
||||
])`)
|
||||
console.log('[GroupReport] capture assets ready')
|
||||
const metrics = (await reportWindow.webContents.executeJavaScript(`({
|
||||
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
|
||||
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, ${captureWidth})),
|
||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||
})`)) as { width: number; height: number }
|
||||
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width)))
|
||||
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
|
||||
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
||||
reportWindow.setContentSize(width, height)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
@@ -587,7 +832,12 @@ export const exportGroupReport = async (
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
|
||||
const templateLabel =
|
||||
request.templateId === 'v1'
|
||||
? '经典版'
|
||||
: request.templateId === 'v2'
|
||||
? '丰富版'
|
||||
: getReportTemplate(request.templateId).fileLabel
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
@@ -596,7 +846,7 @@ export const exportGroupReport = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
@@ -622,3 +872,43 @@ export const exportGroupReport = async (
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export const exportGroupReportSnapshot = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
try {
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel = getReportTemplate(request.templateId).fileLabel
|
||||
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
const htmlStartedAt = new Date()
|
||||
const html = await renderReportSnapshotHtml(request)
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
htmlPath,
|
||||
pngPath,
|
||||
imageDataUrl,
|
||||
exportTimings: {
|
||||
html: {
|
||||
startedAt: htmlStartedAt.toISOString(),
|
||||
endedAt: htmlEndedAt.toISOString(),
|
||||
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
|
||||
},
|
||||
png: {
|
||||
startedAt: pngStartedAt.toISOString(),
|
||||
endedAt: pngEndedAt.toISOString(),
|
||||
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
ready: isReady(),
|
||||
service: 'WechatExplorer Reader',
|
||||
service: 'TraceMemo Reader',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
@@ -186,7 +186,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/contact': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const filter = url.searchParams.get('filter') || undefined
|
||||
const type = url.searchParams.get('type') || undefined
|
||||
let contacts = listContacts(filter)
|
||||
@@ -197,7 +197,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/chatroom': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
let groups = listContacts().filter((c) => c.type === 'group')
|
||||
if (keyword) {
|
||||
@@ -212,14 +212,14 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/recent_chat': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||
const items = listRecentChat(limit)
|
||||
sendJson(res, 200, { count: items.length, items })
|
||||
},
|
||||
|
||||
'/api/v1/chatlog': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const talker = url.searchParams.get('talker')
|
||||
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||
|
||||
@@ -257,7 +257,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/group_snapshot': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const md5 = url.searchParams.get('md5')
|
||||
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||
const snapshot = getGroupSnapshot(md5)
|
||||
@@ -266,7 +266,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/resolve': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const q = url.searchParams.get('q')
|
||||
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||
const contact = resolveMd5(q)
|
||||
@@ -276,7 +276,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
|
||||
'/api/v1/report': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
|
||||
}
|
||||
@@ -300,7 +300,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
|
||||
'/api/v1/agent/group-report': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
||||
try {
|
||||
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||
|
||||
@@ -11,7 +11,9 @@ import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
|
||||
import { loadSettings } from './services/settings-store'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||
const imageDecryptDebugEnabled =
|
||||
process.env['TRACEMEMO_DEBUG_IMAGE'] === '1' ||
|
||||
(!process.env['TRACEMEMO_DEBUG_IMAGE'] && process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1')
|
||||
const imageDecryptLog = (...args: unknown[]): void => {
|
||||
if (imageDecryptDebugEnabled) console.log(...args)
|
||||
}
|
||||
@@ -562,7 +564,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 md5 查找图片文件 (WechatExplorer 风格)
|
||||
* 根据 md5 查找图片文件
|
||||
*/
|
||||
findImageFile(md5?: string, imageDatName?: string, options?: ImageFindOptions): string | null {
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
@@ -648,7 +650,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||
// 尝试 TraceMemo 兼容的微信目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||
if (!existsSync(attachDir)) {
|
||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return rememberPath(
|
||||
|
||||
+209
-32
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
isLegacyMigrationHelper,
|
||||
isUserDataIsolated,
|
||||
roots as appDataRoots
|
||||
} from './app-data-bootstrap'
|
||||
import './preload-env'
|
||||
import {
|
||||
app,
|
||||
@@ -27,14 +32,27 @@ import {
|
||||
inspectImageDecoderStatus,
|
||||
type DecodedImage
|
||||
} from './image-decrypt-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import {
|
||||
exportGroupReport,
|
||||
exportGroupReportSnapshot,
|
||||
extractGroupReportRenderSnapshot
|
||||
} from './group-report-service'
|
||||
import {
|
||||
deleteGeneratedReport,
|
||||
listGeneratedReports,
|
||||
saveGeneratedReport
|
||||
prepareGeneratedReportTemplateSwitch,
|
||||
saveGeneratedReport,
|
||||
updateGeneratedReportTemplate
|
||||
} from './report-history-service'
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||
import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type {
|
||||
SaveGeneratedReportRequest,
|
||||
PrepareGeneratedReportTemplateSwitchRequest,
|
||||
UpdateGeneratedReportTemplateRequest
|
||||
} from '../shared/report-history'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AiSearchExternalAuthorizationRequest,
|
||||
@@ -97,7 +115,7 @@ import { agentHubService } from './services/agent-hub-service'
|
||||
import { appLogger } from './app-logger'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { appUpdateService } from './services/app-update-service'
|
||||
import { clearCache, getCacheSummary } from './services/cache-service'
|
||||
import { clearCache, getCacheSummary, openKnowledgeDirectory } from './services/cache-service'
|
||||
import type { CacheClearScope } from './services/cache-service'
|
||||
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
@@ -109,8 +127,20 @@ import { VoiceBatchService } from './voice-pipeline/voice-batch-service'
|
||||
import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition'
|
||||
import type { AiSearchPipelineRequest } from '../shared/ai-search'
|
||||
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
|
||||
import {
|
||||
isWindowsVcRuntimeMissingError,
|
||||
WINDOWS_VC_RUNTIME_ERROR_MESSAGE
|
||||
} from '../shared/windows-runtime'
|
||||
import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
|
||||
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
|
||||
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
|
||||
import { runFirstLaunchMigration } from './app-data-migration'
|
||||
import { WechatShareConfigStore } from './wechat-share-config-store'
|
||||
import { WechatShareCardService } from './wechat-share-card-service'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
WechatShareServiceConfig
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
// 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
|
||||
@@ -130,6 +160,8 @@ const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const aiProviderService = new AIProviderService()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||
const wechatShareCardService = new WechatShareCardService(wechatShareConfigStore)
|
||||
let tray: Tray | null = null
|
||||
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
let recallProtectionGeneration = 0
|
||||
@@ -268,20 +300,10 @@ protocol.registerSchemesAsPrivileged([
|
||||
}
|
||||
])
|
||||
|
||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||
app.setName(
|
||||
process.platform === 'win32'
|
||||
? 'WeFlow'
|
||||
: process.env['WXE_USER_DATA']
|
||||
? 'WechatExplorer Dev'
|
||||
: 'WechatExplorer'
|
||||
)
|
||||
const isolatedUserData = process.env['WXE_USER_DATA']
|
||||
if (isolatedUserData) app.setPath('userData', isolatedUserData)
|
||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||
null
|
||||
let appShutdownRequested = false
|
||||
let isQuitting = false
|
||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
const TRAY_MODE =
|
||||
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
||||
@@ -416,11 +438,48 @@ function createWindow(): void {
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
let closePromptInFlight = false
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (isQuitting || appShutdownRequested) return
|
||||
event.preventDefault()
|
||||
if (closePromptInFlight) return
|
||||
closePromptInFlight = true
|
||||
void dialog
|
||||
.showMessageBox(mainWindow, {
|
||||
type: 'question',
|
||||
title: '关闭 TraceMemo',
|
||||
message: '请选择关闭方式',
|
||||
detail: '你可以将窗口隐藏到系统托盘,或退出整个应用进程。',
|
||||
buttons: ['最小化到系统托盘', '关闭进程', '取消'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
noLink: true
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (response === 0) {
|
||||
setupTray()
|
||||
mainWindow.hide()
|
||||
if (process.platform === 'darwin') app.dock?.hide()
|
||||
return
|
||||
}
|
||||
if (response === 1) {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Window] close prompt failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
closePromptInFlight = false
|
||||
})
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
@@ -438,6 +497,74 @@ function createWindow(): void {
|
||||
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(async () => {
|
||||
if (isLegacyMigrationHelper) {
|
||||
try {
|
||||
runLegacySafeStorageHelper()
|
||||
app.exit(0)
|
||||
} catch {
|
||||
app.exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const migration = isUserDataIsolated ? null : await runFirstLaunchMigration(appDataRoots)
|
||||
apiTokenStore.setAutomaticGenerationBlocked(migration?.tokenBlockReason)
|
||||
if (!migration) {
|
||||
appLogger.write({
|
||||
level: 'info',
|
||||
scope: 'app-data-migration',
|
||||
message: '隔离 userData 已启用,跳过真实用户数据迁移'
|
||||
})
|
||||
} else {
|
||||
appLogger.write({
|
||||
level: migration.assessment.selection.legacyConflict ? 'warn' : 'info',
|
||||
scope: 'app-data-migration',
|
||||
message: migration.assessment.selection.legacyConflict
|
||||
? '检测到两个独立的 legacy userData,已按兼容优先级选择 WechatExplorer 作为迁移源'
|
||||
: 'TraceMemo 数据身份检查完成',
|
||||
details: {
|
||||
action: migration.action,
|
||||
reason: migration.assessment.reason,
|
||||
sourceRoot: migration.assessment.sourceRoot,
|
||||
targetRoot: appDataRoots.current,
|
||||
legacyExists: migration.assessment.selection.directories.legacy,
|
||||
legacyPackageExists: migration.assessment.selection.directories.legacyPackage,
|
||||
legacyAssets: migration.assessment.selection.assets.legacy,
|
||||
legacyPackageAssets: migration.assessment.selection.assets.legacyPackage,
|
||||
legacyRootsEquivalent: migration.assessment.selection.legacyRootsEquivalent,
|
||||
legacyConflict: migration.assessment.selection.legacyConflict,
|
||||
migrationStatus: migration.execution?.state.status,
|
||||
tokenGenerationBlocked: migration.tokenGenerationBlocked
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const targetToken = join(appDataRoots.current, 'local-api-token.bin')
|
||||
const legacyTokenExists = [appDataRoots.legacy, appDataRoots.legacyPackage].some((root) =>
|
||||
existsSync(join(root, 'local-api-token.bin'))
|
||||
)
|
||||
if (!existsSync(targetToken) && legacyTokenExists) {
|
||||
apiTokenStore.setAutomaticGenerationBlocked(
|
||||
'旧版 API Token 迁移未完成,本地 API 已安全停用。请保留旧数据并重新启动迁移。'
|
||||
)
|
||||
}
|
||||
appLogger.write({
|
||||
level: 'error',
|
||||
scope: 'app-data-migration',
|
||||
message: 'TraceMemo 数据迁移初始化失败',
|
||||
details: { error: error instanceof Error ? error.message : String(error) }
|
||||
})
|
||||
await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'TraceMemo 数据迁移',
|
||||
message: '旧数据迁移未能启动',
|
||||
detail:
|
||||
'旧目录没有被修改或删除。请保留 WechatExplorer 数据并重新启动 TraceMemo;旧 API Token 不会被静默替换。',
|
||||
buttons: ['好']
|
||||
})
|
||||
}
|
||||
|
||||
voiceRecognition = new VoiceRecognitionUseCase({
|
||||
modelRoot: join(app.getPath('userData'), 'models', 'sensevoice-small-int8'),
|
||||
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
|
||||
@@ -474,11 +601,11 @@ app.whenReady().then(async () => {
|
||||
return new Response('Media unavailable', { status: 500 })
|
||||
}
|
||||
})
|
||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||
console.log(`TraceMemo main build: ${BUILD_MARK}`)
|
||||
appLogger.write({
|
||||
level: 'info',
|
||||
scope: 'lifecycle',
|
||||
message: 'WechatExplorer 启动',
|
||||
message: 'TraceMemo 启动',
|
||||
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
||||
})
|
||||
process.on('uncaughtException', (error) => {
|
||||
@@ -506,9 +633,16 @@ app.whenReady().then(async () => {
|
||||
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||
console.log('[WCDB4] async bootstrap complete')
|
||||
})
|
||||
void wcdbBootstrapPromise.catch((error) => {
|
||||
appLogger.write({
|
||||
level: 'error',
|
||||
scope: 'wcdb-bootstrap',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
|
||||
// 设置应用程序用户模型 ID
|
||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||
electronApp.setAppUserModelId('com.tracememo.app')
|
||||
|
||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
||||
|
||||
@@ -529,6 +663,7 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('app-update:download', () => appUpdateService.download())
|
||||
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
||||
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
||||
ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory())
|
||||
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
||||
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'knowledge', 'all']
|
||||
if (!allowedScopes.includes(scope)) return getCacheSummary()
|
||||
@@ -625,7 +760,16 @@ app.whenReady().then(async () => {
|
||||
return { success: true, monitoring }
|
||||
} catch (error) {
|
||||
console.error('Failed to init DB:', error)
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
if (isWindowsVcRuntimeMissingError(detail, process.platform)) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'VC_RUNTIME_MISSING',
|
||||
error: WINDOWS_VC_RUNTIME_ERROR_MESSAGE,
|
||||
monitoring: false
|
||||
}
|
||||
}
|
||||
return { success: false, error: detail }
|
||||
} finally {
|
||||
dbInitInFlight = null
|
||||
}
|
||||
@@ -973,6 +1117,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
ipcMain.handle('ai:listProviders', () => aiProviderService.list())
|
||||
ipcMain.handle('ai:getRuntimeConfig', () => aiProviderService.getRuntimeConfig())
|
||||
ipcMain.handle('ai:getVisionRuntimeConfig', () => aiProviderService.getVisionRuntimeConfig())
|
||||
ipcMain.handle('ai:saveProvider', (_, provider: AIProviderConfig) =>
|
||||
aiProviderService.save(provider)
|
||||
)
|
||||
@@ -1009,12 +1154,21 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'report:exportSnapshot',
|
||||
async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request)
|
||||
)
|
||||
|
||||
ipcMain.handle('export:start', async (event, request: ExportRequest) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window) return { success: false, error: '窗口不可用' }
|
||||
return runExport(request, window, voiceRecognition || undefined)
|
||||
})
|
||||
ipcMain.handle('export:selectDirectory', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, { properties: ['openDirectory', 'createDirectory'] })
|
||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||
})
|
||||
ipcMain.handle('export:cancel', (_, jobId: string) => {
|
||||
cancelExport(jobId)
|
||||
return { success: true }
|
||||
@@ -1035,6 +1189,19 @@ app.whenReady().then(async () => {
|
||||
return saveGeneratedReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'report:updateGeneratedTemplate',
|
||||
async (_, request: UpdateGeneratedReportTemplateRequest) => {
|
||||
return updateGeneratedReportTemplate(request)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'report:prepareTemplateSwitch',
|
||||
async (_, request: PrepareGeneratedReportTemplateSwitchRequest) =>
|
||||
prepareGeneratedReportTemplateSwitch(request.reportId, extractGroupReportRenderSnapshot)
|
||||
)
|
||||
|
||||
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
|
||||
return deleteGeneratedReport(reportId)
|
||||
})
|
||||
@@ -1048,6 +1215,14 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('wechat-share:getConfig', async () => wechatShareConfigStore.status())
|
||||
ipcMain.handle('wechat-share:saveConfig', async (_, config: WechatShareServiceConfig) =>
|
||||
wechatShareConfigStore.save(config)
|
||||
)
|
||||
ipcMain.handle('wechat-share:publish', async (_, request: PublishWechatShareCardRequest) =>
|
||||
wechatShareCardService.publish(request)
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getVoiceData',
|
||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||
@@ -1093,6 +1268,10 @@ app.whenReady().then(async () => {
|
||||
return voiceRecognition.recognize(reference)
|
||||
})
|
||||
|
||||
ipcMain.handle('voice:getTranscriptSnapshot', (_, reference: VoiceMessageReference) => {
|
||||
return voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' as const }
|
||||
})
|
||||
|
||||
ipcMain.handle('voice:getBatchPreflight', (_, request: VoiceBatchRequest) => {
|
||||
if (!voiceBatchService) throw new Error('Voice recognition is not initialized')
|
||||
return voiceBatchService.preflight(request)
|
||||
@@ -1547,10 +1726,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
await agentHubService.start(settings)
|
||||
|
||||
if (TRAY_MODE) {
|
||||
app.dock?.hide()
|
||||
setupTray()
|
||||
}
|
||||
setupTray()
|
||||
if (TRAY_MODE) app.dock?.hide()
|
||||
|
||||
app.on('activate', function () {
|
||||
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
||||
@@ -1574,6 +1751,7 @@ let quitCleanupComplete = false
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (quitCleanupComplete) return
|
||||
isQuitting = true
|
||||
appShutdownRequested = true
|
||||
event.preventDefault()
|
||||
if (quitCleanupStarted) return
|
||||
@@ -1613,7 +1791,7 @@ app.on('before-quit', (event) => {
|
||||
})
|
||||
|
||||
function showMainWindow(): void {
|
||||
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
|
||||
if (process.platform === 'darwin') app.dock?.show().catch(() => undefined)
|
||||
const wins = BrowserWindow.getAllWindows()
|
||||
if (wins.length === 0) {
|
||||
createWindow()
|
||||
@@ -1631,13 +1809,9 @@ function buildTrayMenu(): Menu {
|
||||
label: '打开主窗口',
|
||||
click: () => showMainWindow()
|
||||
},
|
||||
{
|
||||
label: 'API 状态',
|
||||
click: () => showMainWindow()
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出 WechatExplorer',
|
||||
label: '退出 TraceMemo',
|
||||
click: () => {
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
@@ -1656,9 +1830,12 @@ function setupTray(): void {
|
||||
? nativeImage.createEmpty()
|
||||
: image.resize({ width: traySize, height: traySize, quality: 'best' })
|
||||
tray = new Tray(trayImage)
|
||||
tray.setToolTip('WechatExplorer')
|
||||
tray.setContextMenu(buildTrayMenu())
|
||||
tray.setToolTip('TraceMemo')
|
||||
// macOS may show a Tray context menu on a primary click when it is set
|
||||
// directly on the Tray. Keep the menu for an explicit secondary click so
|
||||
// the primary click only restores the main window.
|
||||
tray.on('click', () => showMainWindow())
|
||||
tray.on('right-click', () => tray?.popUpContextMenu(buildTrayMenu()))
|
||||
} catch (error) {
|
||||
console.warn('[Tray] Failed to create tray:', error)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export class KeyServiceMac {
|
||||
code,
|
||||
error:
|
||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||
'建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export class KeyServiceMac {
|
||||
'return "ERR::" & errNum & "::" & errMsg',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('授权后请保持微信登录界面 并点击登录微信...')
|
||||
onStatus?.('授权后 需要在微信登录界面 点击登录微信')
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/osascript',
|
||||
scriptLines.flatMap((line) => ['-e', line]),
|
||||
|
||||
+207
-357
@@ -1,9 +1,6 @@
|
||||
// @ts-nocheck
|
||||
// Windows native bridge adapted from WeFlow. Koffi Win32 callbacks and optional
|
||||
// helpers are intentionally dynamic; keep this file isolated from strict TS.
|
||||
import { join, dirname, delimiter } from 'path'
|
||||
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import os from 'os'
|
||||
import crypto from 'crypto'
|
||||
@@ -12,14 +9,19 @@ import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
type ImageKeyResult = {
|
||||
success: boolean
|
||||
xorKey?: number
|
||||
aesKey?: string
|
||||
verified?: boolean
|
||||
error?: string
|
||||
}
|
||||
type DbKeyPollResult =
|
||||
| { status: 'success'; key: string; loginRequiredDetected: boolean }
|
||||
| { status: 'process-ended'; loginRequiredDetected: boolean }
|
||||
| { status: 'timeout'; loginRequiredDetected: boolean }
|
||||
|
||||
export class KeyService {
|
||||
private readonly isMac = process.platform === 'darwin'
|
||||
private koffi: any = null
|
||||
private lib: any = null
|
||||
private initialized = false
|
||||
@@ -28,19 +30,15 @@ export class KeyService {
|
||||
private getStatusMessage: any = null
|
||||
private cleanupHook: any = null
|
||||
private getLastErrorMsg: any = null
|
||||
private getImageKeyDll: any = null
|
||||
private lastLoadError = ''
|
||||
|
||||
// Win32 APIs
|
||||
private kernel32: any = null
|
||||
private user32: any = null
|
||||
private advapi32: any = null
|
||||
|
||||
// Kernel32
|
||||
private OpenProcess: any = null
|
||||
private CloseHandle: any = null
|
||||
private TerminateProcess: any = null
|
||||
private QueryFullProcessImageNameW: any = null
|
||||
|
||||
// User32
|
||||
private EnumWindows: any = null
|
||||
@@ -50,22 +48,9 @@ export class KeyService {
|
||||
private GetWindowThreadProcessId: any = null
|
||||
private IsWindowVisible: any = null
|
||||
private EnumChildWindows: any = null
|
||||
private PostMessageW: any = null
|
||||
private WNDENUMPROC_PTR: any = null
|
||||
|
||||
// Advapi32
|
||||
private RegOpenKeyExW: any = null
|
||||
private RegQueryValueExW: any = null
|
||||
private RegCloseKey: any = null
|
||||
|
||||
// Constants
|
||||
private readonly PROCESS_ALL_ACCESS = 0x1F0FFF
|
||||
private readonly PROCESS_TERMINATE = 0x0001
|
||||
private readonly KEY_READ = 0x20019
|
||||
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
||||
private readonly HKEY_CURRENT_USER = 0x80000001
|
||||
private readonly ERROR_SUCCESS = 0
|
||||
private readonly WM_CLOSE = 0x0010
|
||||
private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000
|
||||
|
||||
private getResourceRoots(): string[] {
|
||||
@@ -156,11 +141,11 @@ export class KeyService {
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
|
||||
this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)')
|
||||
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
|
||||
this.getStatusMessage = this.lib.func(
|
||||
'bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)'
|
||||
)
|
||||
this.cleanupHook = this.lib.func('bool CleanupHook()')
|
||||
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
|
||||
this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)')
|
||||
|
||||
this.initialized = true
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -186,8 +171,6 @@ export class KeyService {
|
||||
this.kernel32 = this.koffi.load('kernel32.dll')
|
||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
|
||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
|
||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32'])
|
||||
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -211,12 +194,26 @@ export class KeyService {
|
||||
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
|
||||
|
||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', [
|
||||
'void*',
|
||||
this.WNDENUMPROC_PTR,
|
||||
'intptr_t'
|
||||
])
|
||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', [
|
||||
'void*',
|
||||
this.koffi.out('uint16*'),
|
||||
'int'
|
||||
])
|
||||
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')])
|
||||
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', [
|
||||
'void*',
|
||||
this.koffi.out('uint16*'),
|
||||
'int'
|
||||
])
|
||||
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', [
|
||||
'void*',
|
||||
this.koffi.out('uint32*')
|
||||
])
|
||||
this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*'])
|
||||
|
||||
return true
|
||||
@@ -226,26 +223,6 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAdvapi32(): boolean {
|
||||
if (this.advapi32) return true
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
this.advapi32 = this.koffi.load('advapi32.dll')
|
||||
|
||||
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
|
||||
const HKEY_PTR = this.koffi.pointer(HKEY)
|
||||
|
||||
this.RegOpenKeyExW = this.advapi32.func('RegOpenKeyExW', 'long', [HKEY, 'uint16*', 'uint32', 'uint32', this.koffi.out(HKEY_PTR)])
|
||||
this.RegQueryValueExW = this.advapi32.func('RegQueryValueExW', 'long', [HKEY, 'uint16*', 'uint32*', this.koffi.out('uint32*'), this.koffi.out('uint8*'), this.koffi.out('uint32*')])
|
||||
this.RegCloseKey = this.advapi32.func('RegCloseKey', 'long', [HKEY])
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('初始化 advapi32 失败:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private decodeCString(ptr: any): string {
|
||||
try {
|
||||
if (typeof ptr === 'string') return ptr
|
||||
@@ -255,123 +232,19 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- WeChat Process & Path Finding ---
|
||||
|
||||
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
|
||||
if (!this.ensureAdvapi32()) return null
|
||||
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
|
||||
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
|
||||
const phkResult = Buffer.alloc(8)
|
||||
|
||||
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) return null
|
||||
|
||||
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
|
||||
|
||||
try {
|
||||
const lpcbData = Buffer.alloc(4)
|
||||
lpcbData.writeUInt32LE(0, 0)
|
||||
|
||||
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
|
||||
if (ret !== this.ERROR_SUCCESS) return null
|
||||
|
||||
const size = lpcbData.readUInt32LE(0)
|
||||
if (size === 0) return null
|
||||
|
||||
const dataBuf = Buffer.alloc(size)
|
||||
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
|
||||
if (ret !== this.ERROR_SUCCESS) return null
|
||||
|
||||
let str = dataBuf.toString('ucs2')
|
||||
if (str.endsWith('\0')) str = str.slice(0, -1)
|
||||
return str
|
||||
} finally {
|
||||
this.RegCloseKey(hKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
const hProcess = this.OpenProcess(0x1000, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const sizeBuf = Buffer.alloc(4)
|
||||
sizeBuf.writeUInt32LE(1024, 0)
|
||||
const pathBuf = Buffer.alloc(1024 * 2)
|
||||
|
||||
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
|
||||
if (ret) {
|
||||
const len = sizeBuf.readUInt32LE(0)
|
||||
return pathBuf.toString('ucs2', 0, len * 2)
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
console.error('获取进程路径失败:', e)
|
||||
return null
|
||||
} finally {
|
||||
this.CloseHandle(hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
private async findWeChatInstallPath(): Promise<string | null> {
|
||||
try {
|
||||
const pid = await this.findWeChatPid()
|
||||
if (pid) {
|
||||
const runPath = await this.getProcessExecutablePath(pid)
|
||||
if (runPath && existsSync(runPath)) return runPath
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('尝试获取运行中微信路径失败:', e)
|
||||
}
|
||||
|
||||
const uninstallKeys = [
|
||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
|
||||
]
|
||||
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
|
||||
const tencentKeys = [
|
||||
'Software\\Tencent\\WeChat',
|
||||
'Software\\WOW6432Node\\Tencent\\WeChat',
|
||||
'Software\\Tencent\\Weixin',
|
||||
]
|
||||
|
||||
for (const root of roots) {
|
||||
for (const key of tencentKeys) {
|
||||
const path = this.readRegistryString(root, key, 'InstallPath')
|
||||
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
|
||||
if (path && existsSync(join(path, 'WeChat.exe'))) return join(path, 'WeChat.exe')
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
for (const parent of uninstallKeys) {
|
||||
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
|
||||
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
|
||||
}
|
||||
}
|
||||
|
||||
const drives = ['C', 'D', 'E', 'F']
|
||||
const commonPaths = [
|
||||
'Program Files\\Tencent\\WeChat\\WeChat.exe',
|
||||
'Program Files (x86)\\Tencent\\WeChat\\WeChat.exe',
|
||||
'Program Files\\Tencent\\Weixin\\Weixin.exe',
|
||||
'Program Files (x86)\\Tencent\\Weixin\\Weixin.exe'
|
||||
]
|
||||
|
||||
for (const drive of drives) {
|
||||
for (const p of commonPaths) {
|
||||
const full = join(drive + ':\\', p)
|
||||
if (existsSync(full)) return full
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async findPidsByImageName(imageName: string): Promise<number[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
|
||||
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||
const { stdout } = await execFileAsync('tasklist', [
|
||||
'/FI',
|
||||
`IMAGENAME eq ${imageName}`,
|
||||
'/FO',
|
||||
'CSV',
|
||||
'/NH'
|
||||
])
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const pids: number[] = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('INFO:')) continue
|
||||
@@ -418,7 +291,7 @@ export class KeyService {
|
||||
const fallbackPid = await this.waitForWeChatWindow(250)
|
||||
if (fallbackPid) return fallbackPid
|
||||
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -428,10 +301,10 @@ export class KeyService {
|
||||
}
|
||||
|
||||
private async pollDbKeyFromHook(
|
||||
pid: number,
|
||||
deadline: number,
|
||||
logs: string[],
|
||||
onStatus?: (message: string, level: number) => void
|
||||
pid: number,
|
||||
deadline: number,
|
||||
logs: string[],
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyPollResult> {
|
||||
const keyBuffer = Buffer.alloc(128)
|
||||
let loginRequiredDetected = false
|
||||
@@ -441,7 +314,7 @@ export class KeyService {
|
||||
const now = Date.now()
|
||||
if (now >= nextProcessCheckAt) {
|
||||
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
if (!(await this.isWeChatPidActive(pid))) {
|
||||
return { status: 'process-ended', loginRequiredDetected }
|
||||
}
|
||||
}
|
||||
@@ -477,13 +350,17 @@ export class KeyService {
|
||||
private cleanupDbKeyHook(): void {
|
||||
try {
|
||||
this.cleanupHook()
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private buildInitHookError(): string {
|
||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||
if (error) {
|
||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||
if (
|
||||
error.includes('0xC0000022') ||
|
||||
error.includes('ACCESS_DENIED') ||
|
||||
error.includes('打开目标进程失败')
|
||||
) {
|
||||
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
}
|
||||
return error
|
||||
@@ -491,13 +368,17 @@ export class KeyService {
|
||||
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
const levelOut = [0]
|
||||
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
||||
const status =
|
||||
this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
||||
? this.decodeUtf8(statusBuffer)
|
||||
: ''
|
||||
return status || '初始化失败'
|
||||
}
|
||||
|
||||
private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
|
||||
private async waitForNextDbKeyPid(
|
||||
deadline: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<number | null> {
|
||||
while (this.getRemainingMs(deadline) > 0) {
|
||||
onStatus?.('正在查找微信进程...', 0)
|
||||
const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000))
|
||||
@@ -514,17 +395,23 @@ export class KeyService {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
|
||||
private async waitForProcessRestart(
|
||||
deadline: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<number | null> {
|
||||
if (!this.shouldRetryAfterProcessLost(deadline)) return null
|
||||
onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0)
|
||||
await this.delayBeforeRetry()
|
||||
return this.waitForNextDbKeyPid(deadline, onStatus)
|
||||
}
|
||||
|
||||
private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise<boolean> {
|
||||
private async detectLoginRequiredForLastPid(
|
||||
pid: number | null,
|
||||
loginRequiredDetected: boolean
|
||||
): Promise<boolean> {
|
||||
if (loginRequiredDetected) return true
|
||||
if (!pid) return false
|
||||
if (!await this.isWeChatPidActive(pid)) return false
|
||||
if (!(await this.isWeChatPidActive(pid))) return false
|
||||
return await this.detectWeChatLoginRequired(pid)
|
||||
}
|
||||
|
||||
@@ -535,56 +422,6 @@ export class KeyService {
|
||||
return fallbackPid ?? null
|
||||
}
|
||||
|
||||
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const runningPids = await this.findWeChatPids()
|
||||
if (runningPids.length === 0) return true
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async closeWeChatWindows(): Promise<boolean> {
|
||||
if (!this.ensureUser32()) return false
|
||||
let requested = false
|
||||
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
const className = this.getClassName(hWnd)
|
||||
const classLower = (className || '').toLowerCase()
|
||||
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
|
||||
if (!isWeChatWindow) return true
|
||||
|
||||
requested = true
|
||||
try {
|
||||
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
|
||||
} catch { }
|
||||
return true
|
||||
}, this.WNDENUMPROC_PTR)
|
||||
|
||||
this.EnumWindows(enumWindowsCallback, 0)
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
return requested
|
||||
}
|
||||
|
||||
private async killWeChatProcesses(): Promise<boolean> {
|
||||
const requested = await this.closeWeChatWindows()
|
||||
if (requested) {
|
||||
const gracefulOk = await this.waitForWeChatExit(1500)
|
||||
if (gracefulOk) return true
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
||||
} catch (e) { }
|
||||
|
||||
return await this.waitForWeChatExit(5000)
|
||||
}
|
||||
|
||||
// --- Window Detection ---
|
||||
|
||||
private getWindowTitle(hWnd: any): string {
|
||||
@@ -614,7 +451,7 @@ export class KeyService {
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
let foundPid: number | null = null
|
||||
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
if (!this.isWeChatWindowTitle(title)) return true
|
||||
@@ -633,14 +470,14 @@ export class KeyService {
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
if (foundPid) return foundPid
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
|
||||
const children: Array<{ title: string; className: string }> = []
|
||||
const enumChildCallback = this.koffi.register((hChild: any, lp: any) => {
|
||||
const enumChildCallback = this.koffi.register((hChild: any, _lp: any) => {
|
||||
const title = this.getWindowTitle(hChild).trim()
|
||||
const className = this.getClassName(hChild).trim()
|
||||
children.push({ title, className })
|
||||
@@ -655,7 +492,16 @@ export class KeyService {
|
||||
if (children.length === 0) return false
|
||||
|
||||
const readyTexts = ['聊天', '登录', '账号']
|
||||
const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', 'BrowserWnd', 'ListView']
|
||||
const readyClassMarkers = [
|
||||
'WeChat',
|
||||
'Weixin',
|
||||
'TXGuiFoundation',
|
||||
'Qt5',
|
||||
'ChatList',
|
||||
'MainWnd',
|
||||
'BrowserWnd',
|
||||
'ListView'
|
||||
]
|
||||
const readyChildCountThreshold = 14
|
||||
|
||||
let classMatchCount = 0
|
||||
@@ -665,12 +511,12 @@ export class KeyService {
|
||||
for (const child of children) {
|
||||
const normalizedTitle = child.title.replace(/\s+/g, '')
|
||||
if (normalizedTitle) {
|
||||
if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true
|
||||
if (readyTexts.some((marker) => normalizedTitle.includes(marker))) return true
|
||||
titleMatchCount += 1
|
||||
}
|
||||
const className = child.className
|
||||
if (className) {
|
||||
if (readyClassMarkers.some(marker => className.includes(marker))) return true
|
||||
if (readyClassMarkers.some((marker) => className.includes(marker))) return true
|
||||
if (className.length > 5) {
|
||||
classMatchCount += 1
|
||||
hasValidClassName = true
|
||||
@@ -685,7 +531,9 @@ export class KeyService {
|
||||
}
|
||||
|
||||
private isLoginRelatedText(value: string): boolean {
|
||||
const normalized = String(value || '').replace(/\s+/g, '').toLowerCase()
|
||||
const normalized = String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
if (!normalized) return false
|
||||
const keywords = [
|
||||
'登录',
|
||||
@@ -741,7 +589,7 @@ export class KeyService {
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
let ready = false
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
if (!this.isWeChatWindowTitle(title)) return true
|
||||
@@ -763,7 +611,7 @@ export class KeyService {
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
if (ready) return true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -771,8 +619,8 @@ export class KeyService {
|
||||
// --- DB Key Logic (core hook/poll flow unchanged) ---
|
||||
|
||||
async autoGetDbKey(
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyResult> {
|
||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||
if (!this.ensureLoaded()) return { success: false, error: this.getLoadError() }
|
||||
@@ -794,14 +642,14 @@ export class KeyService {
|
||||
onStatus?.('正在检测微信界面组件...', 0)
|
||||
await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline)))
|
||||
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
if (!(await this.isWeChatPidActive(pid))) {
|
||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||
continue
|
||||
}
|
||||
|
||||
const ok = this.initHook(pid)
|
||||
if (!ok) {
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
if (!(await this.isWeChatPidActive(pid))) {
|
||||
this.cleanupDbKeyHook()
|
||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||
continue
|
||||
@@ -828,11 +676,14 @@ export class KeyService {
|
||||
break
|
||||
}
|
||||
|
||||
const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected)
|
||||
const loginRequired = await this.detectLoginRequiredForLastPid(
|
||||
pid,
|
||||
lastAttemptLoginRequiredDetected
|
||||
)
|
||||
if (loginRequired) {
|
||||
return {
|
||||
success: false,
|
||||
error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。',
|
||||
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
||||
logs
|
||||
}
|
||||
}
|
||||
@@ -840,81 +691,10 @@ export class KeyService {
|
||||
return { success: false, error: '获取密钥超时', logs }
|
||||
}
|
||||
|
||||
private cleanWxid(wxid: string): string {
|
||||
const first = wxid.indexOf('_')
|
||||
if (first === -1) return wxid
|
||||
const second = wxid.indexOf('_', first + 1)
|
||||
if (second === -1) return wxid
|
||||
return wxid.substring(0, second)
|
||||
}
|
||||
|
||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
const xorKey = code & 0xFF
|
||||
const dataToHash = code.toString() + cleanedWxid
|
||||
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
||||
const aesKey = md5Full.substring(0, 16)
|
||||
return { xorKey, aesKey }
|
||||
}
|
||||
|
||||
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||
try {
|
||||
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
|
||||
const candidates: string[] = []
|
||||
const pushUnique = (value: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v || candidates.includes(v)) return
|
||||
candidates.push(v)
|
||||
}
|
||||
|
||||
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||
|
||||
if (manualDir) {
|
||||
const normalized = manualDir.replace(/[\\/]+$/, '')
|
||||
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||
|
||||
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
|
||||
const marker = normalized.match(/[\\/]xwechat_files/i)
|
||||
if (marker) {
|
||||
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||
try {
|
||||
const { readdirSync, statSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
for (const entry of readdirSync(root)) {
|
||||
if (!entry.startsWith('wxid_')) continue
|
||||
const full = join(root, entry)
|
||||
try {
|
||||
if (statSync(full).isDirectory()) pushUnique(entry)
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
pushUnique('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
async autoGetImageKey(
|
||||
manualDir?: string,
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
manualDir?: string,
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
void wxidParam
|
||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
|
||||
@@ -935,9 +715,16 @@ export class KeyService {
|
||||
onProgress?.('正在查找模板文件...')
|
||||
let result = await this._findTemplateData(userDir, 32)
|
||||
let { ciphertext, xorKey } = result
|
||||
const firstDiag = (this as { _imageTemplateDiag?: {
|
||||
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||
} })._imageTemplateDiag
|
||||
const firstDiag = (
|
||||
this as {
|
||||
_imageTemplateDiag?: {
|
||||
userDir: string
|
||||
totalTFiles: number
|
||||
v2Count: number
|
||||
nonV2Count: number
|
||||
}
|
||||
}
|
||||
)._imageTemplateDiag
|
||||
|
||||
// 如果找不到密钥,尝试扫描更多文件
|
||||
if (ciphertext && xorKey === null) {
|
||||
@@ -948,9 +735,17 @@ export class KeyService {
|
||||
|
||||
if (!ciphertext) {
|
||||
// 用诊断信息给具体提示
|
||||
const diag = (this as { _imageTemplateDiag?: {
|
||||
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||
} })._imageTemplateDiag || firstDiag
|
||||
const diag =
|
||||
(
|
||||
this as {
|
||||
_imageTemplateDiag?: {
|
||||
userDir: string
|
||||
totalTFiles: number
|
||||
v2Count: number
|
||||
nonV2Count: number
|
||||
}
|
||||
}
|
||||
)._imageTemplateDiag || firstDiag
|
||||
if (!diag || diag.totalTFiles === 0) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -978,7 +773,11 @@ export class KeyService {
|
||||
'请在微信中查看更多图片后再试。'
|
||||
}
|
||||
}
|
||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||
if (xorKey === null)
|
||||
return {
|
||||
success: false,
|
||||
error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片'
|
||||
}
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
@@ -1000,7 +799,7 @@ export class KeyService {
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
// 等 5 秒再试
|
||||
await new Promise(r => setTimeout(r, 5000))
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1012,7 +811,10 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
private async _findTemplateData(
|
||||
userDir: string,
|
||||
limit: number = 32
|
||||
): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
const { readdirSync, readFileSync, statSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
@@ -1027,7 +829,9 @@ export class KeyService {
|
||||
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||
}
|
||||
} catch { /* 忽略无权限目录 */ }
|
||||
} catch {
|
||||
/* 忽略无权限目录 */
|
||||
}
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
@@ -1035,7 +839,11 @@ export class KeyService {
|
||||
|
||||
// 按修改时间降序
|
||||
files.sort((a, b) => {
|
||||
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||
try {
|
||||
return statSync(b).mtimeMs - statSync(a).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
let ciphertext: Buffer | null = null
|
||||
@@ -1058,17 +866,24 @@ export class KeyService {
|
||||
}
|
||||
|
||||
// 提取密文(取第一个有效的)
|
||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||
ciphertext = data.subarray(0xF, 0x1F)
|
||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1f) {
|
||||
ciphertext = data.subarray(0xf, 0x1f)
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 XOR 密钥
|
||||
let xorKey: number | null = null
|
||||
let maxCount = 0
|
||||
for (const [key, count] of Object.entries(tailCounts)) {
|
||||
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
const [x, y] = key.split('_').map(Number)
|
||||
const k = x ^ 0xff
|
||||
if (k === (y ^ 0xd9)) xorKey = k
|
||||
}
|
||||
}
|
||||
|
||||
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
|
||||
@@ -1091,8 +906,19 @@ export class KeyService {
|
||||
if (!this.ensureKernel32()) return null
|
||||
|
||||
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
|
||||
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t'])
|
||||
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')])
|
||||
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', [
|
||||
'void*',
|
||||
'uintptr',
|
||||
'void*',
|
||||
'size_t'
|
||||
])
|
||||
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', [
|
||||
'void*',
|
||||
'uintptr',
|
||||
'void*',
|
||||
'size_t',
|
||||
this.koffi.out('size_t*')
|
||||
])
|
||||
|
||||
// RW 保护标志(只扫可写区域,速度更快)
|
||||
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
@@ -1101,7 +927,7 @@ export class KeyService {
|
||||
const PAGE_GUARD = 0x100
|
||||
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
|
||||
|
||||
const hProcess = this.OpenProcess(0x1F0FFF, false, pid)
|
||||
const hProcess = this.OpenProcess(0x1f0fff, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
@@ -1110,7 +936,7 @@ export class KeyService {
|
||||
let addr = 0
|
||||
const mbi = Buffer.alloc(MBI_SIZE)
|
||||
|
||||
while (addr < 0x7FFFFFFFFFFF) {
|
||||
while (addr < 0x7fffffffffff) {
|
||||
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
|
||||
if (ret === 0) break
|
||||
// MEMORY_BASIC_INFORMATION x64 布局:
|
||||
@@ -1126,11 +952,13 @@ export class KeyService {
|
||||
const state = mbi.readUInt32LE(32)
|
||||
const protect = mbi.readUInt32LE(36)
|
||||
|
||||
if (state === MEM_COMMIT &&
|
||||
protect !== PAGE_NOACCESS &&
|
||||
(protect & PAGE_GUARD) === 0 &&
|
||||
(protect & RW_FLAGS) !== 0 &&
|
||||
size <= 50 * 1024 * 1024) {
|
||||
if (
|
||||
state === MEM_COMMIT &&
|
||||
protect !== PAGE_NOACCESS &&
|
||||
(protect & PAGE_GUARD) === 0 &&
|
||||
(protect & RW_FLAGS) !== 0 &&
|
||||
size <= 50 * 1024 * 1024
|
||||
) {
|
||||
regions.push([base, size])
|
||||
}
|
||||
const next = base + size
|
||||
@@ -1148,7 +976,7 @@ export class KeyService {
|
||||
const [base, size] = regions[i]
|
||||
if (i % 20 === 0) {
|
||||
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
|
||||
await new Promise(r => setTimeout(r, 1)) // 让出事件循环
|
||||
await new Promise((r) => setTimeout(r, 1)) // 让出事件循环
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
@@ -1159,17 +987,29 @@ export class KeyService {
|
||||
const buf = Buffer.alloc(chunkSize)
|
||||
const bytesReadOut = [0]
|
||||
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
|
||||
if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue }
|
||||
if (!ok || bytesReadOut[0] === 0) {
|
||||
offset += chunkSize
|
||||
trailing = null
|
||||
continue
|
||||
}
|
||||
|
||||
const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0])
|
||||
const data: Buffer = trailing
|
||||
? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])])
|
||||
: buf.subarray(0, bytesReadOut[0])
|
||||
|
||||
// 搜索 ASCII 32字节密钥
|
||||
const key = this._searchAsciiKey(data, ciphertext)
|
||||
if (key) { this.CloseHandle(hProcess); return key }
|
||||
if (key) {
|
||||
this.CloseHandle(hProcess)
|
||||
return key
|
||||
}
|
||||
|
||||
// 搜索 UTF-16LE 32字节密钥
|
||||
const key16 = this._searchUtf16Key(data, ciphertext)
|
||||
if (key16) { this.CloseHandle(hProcess); return key16 }
|
||||
if (key16) {
|
||||
this.CloseHandle(hProcess)
|
||||
return key16
|
||||
}
|
||||
|
||||
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
|
||||
offset += chunkSize
|
||||
@@ -1187,12 +1027,16 @@ export class KeyService {
|
||||
if (this._isAlphaNum(data[i])) continue
|
||||
let valid = true
|
||||
for (let j = 1; j <= 32; j++) {
|
||||
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
|
||||
if (!this._isAlphaNum(data[i + j])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!valid) continue
|
||||
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
|
||||
const keyBytes = data.subarray(i + 1, i + 33)
|
||||
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||
if (this._verifyAesKey(keyBytes, ciphertext))
|
||||
return keyBytes.toString('ascii').substring(0, 16)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1201,18 +1045,22 @@ export class KeyService {
|
||||
for (let i = 0; i < data.length - 65; i++) {
|
||||
let valid = true
|
||||
for (let j = 0; j < 32; j++) {
|
||||
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
|
||||
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!valid) continue
|
||||
const keyBytes = Buffer.alloc(32)
|
||||
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
|
||||
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||
if (this._verifyAesKey(keyBytes, ciphertext))
|
||||
return keyBytes.toString('ascii').substring(0, 16)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private _isAlphaNum(b: number): boolean {
|
||||
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
|
||||
return (b >= 0x61 && b <= 0x7a) || (b >= 0x41 && b <= 0x5a) || (b >= 0x30 && b <= 0x39)
|
||||
}
|
||||
|
||||
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
|
||||
@@ -1221,12 +1069,14 @@ export class KeyService {
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
// 支持 JPEG / PNG / WEBP / WXGF / GIF
|
||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0xff && dec[1] === 0xd8 && dec[2] === 0xff) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4e && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch { return false }
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -123,6 +123,9 @@ export type ParsedContent =
|
||||
| UnknownContent
|
||||
|
||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||
// Voice rows may keep their binary payload outside msgContent, so an empty
|
||||
// content string is still a valid voice message.
|
||||
if (messageType === 34) return { type: 'voice' }
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { type: 'unknown', raw: content || '' }
|
||||
}
|
||||
@@ -132,8 +135,6 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
switch (messageType) {
|
||||
case 1:
|
||||
return { type: 'text', content: normalized }
|
||||
case 34:
|
||||
return { type: 'voice' }
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
@@ -490,8 +491,7 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
}
|
||||
|
||||
const articles = parseShareArticles(content)
|
||||
const title =
|
||||
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||
const title = articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||
const des =
|
||||
articles[0]?.description ||
|
||||
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
|
||||
|
||||
@@ -33,5 +33,5 @@ try {
|
||||
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
|
||||
} catch (error) {
|
||||
console.error('[WechatExplorer] failed to enforce local DLL priority:', error)
|
||||
console.error('[TraceMemo] failed to enforce local DLL priority:', error)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import type {
|
||||
ReportAssetStatus,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
SaveGeneratedReportResult,
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../shared/report-history'
|
||||
import type { GroupReportRenderSnapshot } from '../shared/group-report'
|
||||
|
||||
const REPORTS_DIR = 'reports'
|
||||
|
||||
@@ -78,6 +81,7 @@ const normalizeRecord = async (
|
||||
const pngStatus = await fileStatus(record.pngPath)
|
||||
return {
|
||||
...record,
|
||||
textModelName: record.textModelName || record.modelName,
|
||||
jsonPath,
|
||||
htmlStatus,
|
||||
pngStatus,
|
||||
@@ -178,13 +182,18 @@ export async function saveGeneratedReport(
|
||||
pngStatus: savedPngPath ? 'ready' : 'missing',
|
||||
imageSize: await readPngSize(savedPngPath),
|
||||
duration: request.duration,
|
||||
textModelName: request.textModelName || request.modelName,
|
||||
imageModelName: request.imageModelName,
|
||||
modelName: request.modelName,
|
||||
tokenUsage: request.tokenUsage,
|
||||
fileSize: {
|
||||
html: await readFileSize(savedHtmlPath),
|
||||
png: await readFileSize(savedPngPath)
|
||||
},
|
||||
generationLogs: request.generationLogs
|
||||
generationLogs: request.generationLogs,
|
||||
reportSnapshot: request.reportSnapshot,
|
||||
reportMetadata: request.reportMetadata,
|
||||
templateId: request.templateId
|
||||
}
|
||||
|
||||
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
|
||||
@@ -200,6 +209,124 @@ export async function saveGeneratedReport(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGeneratedReportTemplate(
|
||||
request: UpdateGeneratedReportTemplateRequest
|
||||
): Promise<UpdateGeneratedReportTemplateResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
for (const jsonPath of jsonFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
const record = JSON.parse(content) as GeneratedReportRecord
|
||||
if (record.id !== request.reportId) continue
|
||||
if ((!record.reportSnapshot || !record.reportMetadata) && !record.reportRenderSnapshot) {
|
||||
return { success: false, error: '旧报告未保存结构化数据,无法无损切换模板' }
|
||||
}
|
||||
|
||||
const directory = path.dirname(jsonPath)
|
||||
const baseName = path.basename(jsonPath, '.json')
|
||||
const htmlPath = record.htmlPath || path.join(directory, `${baseName}.html`)
|
||||
const pngPath = record.pngPath || path.join(directory, `${baseName}.png`)
|
||||
|
||||
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
|
||||
const hasPngFile = Boolean(request.pngPath && (await exists(request.pngPath)))
|
||||
if (!request.htmlPath || !(await exists(request.htmlPath))) {
|
||||
return { success: false, error: '新模板 HTML 文件不存在' }
|
||||
}
|
||||
if (!imageBuffer && !hasPngFile) {
|
||||
return { success: false, error: '新模板 PNG 文件不存在' }
|
||||
}
|
||||
await fs.copyFile(request.htmlPath, htmlPath)
|
||||
if (imageBuffer) {
|
||||
await fs.writeFile(pngPath, imageBuffer)
|
||||
} else if (request.pngPath) {
|
||||
await fs.copyFile(request.pngPath, pngPath)
|
||||
}
|
||||
|
||||
const updated: GeneratedReportRecord = {
|
||||
...record,
|
||||
htmlPath,
|
||||
pngPath,
|
||||
jsonPath,
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
imageSize: await readPngSize(pngPath),
|
||||
fileSize: {
|
||||
html: await readFileSize(htmlPath),
|
||||
png: await readFileSize(pngPath)
|
||||
},
|
||||
templateId: request.templateId
|
||||
}
|
||||
delete updated.generatedImage
|
||||
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
|
||||
return {
|
||||
success: true,
|
||||
record: {
|
||||
...updated,
|
||||
generatedImage: await readPngAsDataUrl(pngPath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ReportHistory] skip invalid report record while switching template: ${jsonPath}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return { success: false, error: '未找到要切换模板的日报记录' }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareGeneratedReportTemplateSwitch(
|
||||
reportId: string,
|
||||
extractSnapshot: (
|
||||
htmlPath: string,
|
||||
fallback: {
|
||||
groupName: string
|
||||
reportDate: string
|
||||
dateRange: string
|
||||
messageCount: number
|
||||
generatedAt: string
|
||||
}
|
||||
) => Promise<GroupReportRenderSnapshot>
|
||||
): Promise<import('../shared/report-history').PrepareGeneratedReportTemplateSwitchResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
for (const jsonPath of jsonFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
const record = JSON.parse(content) as GeneratedReportRecord
|
||||
if (record.id !== reportId) continue
|
||||
if (record.reportRenderSnapshot)
|
||||
return { success: true, snapshot: record.reportRenderSnapshot }
|
||||
if (!record.htmlPath || !(await exists(record.htmlPath))) {
|
||||
return { success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' }
|
||||
}
|
||||
const snapshot = await extractSnapshot(record.htmlPath, {
|
||||
groupName: record.contactName,
|
||||
reportDate: record.reportDate,
|
||||
dateRange: record.dateRange,
|
||||
messageCount: record.messageCount,
|
||||
generatedAt: record.generatedAt
|
||||
})
|
||||
const updated = { ...record, reportRenderSnapshot: snapshot }
|
||||
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
|
||||
return { success: true, snapshot }
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ReportHistory] skip invalid report record while preparing template switch: ${jsonPath}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return { success: false, error: '未找到要切换模板的日报记录' }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGeneratedReport(
|
||||
reportId: string
|
||||
): Promise<DeleteGeneratedReportResult> {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function getResourceRoots(): string[] {
|
||||
const execDir = dirname(process.execPath)
|
||||
|
||||
return unique([
|
||||
process.env.TRACEMEMO_RESOURCES_PATH || '',
|
||||
process.env.WECHATEXPLORER_RESOURCES_PATH || '',
|
||||
join(process.cwd(), 'resources'),
|
||||
join(process.cwd(), 'resources', 'resources'),
|
||||
|
||||
@@ -455,7 +455,7 @@ class AgentHubService {
|
||||
|
||||
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const items = listRecentChat(limit)
|
||||
@@ -474,7 +474,7 @@ class AgentHubService {
|
||||
const result = await agentAIProvider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
|
||||
content: `你是 TraceMemo 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
|
||||
支持的工具:
|
||||
1. recent:查看最近会话,参数 limit 为 1-20。
|
||||
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
|
||||
@@ -544,7 +544,7 @@ class AgentHubService {
|
||||
intent: ContactChatIntent
|
||||
): Promise<void> {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ class AgentHubService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const contact = resolveMd5(intent.contact)
|
||||
@@ -642,7 +642,7 @@ class AgentHubService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const group = this.resolveGroup(intent.group)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AIProviderSummary,
|
||||
AiSearchProviderStatus,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionRuntimeConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
LegacyAIConfig
|
||||
@@ -74,6 +75,60 @@ export class AIProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
getVisionRuntimeConfig(): AIVisionRuntimeConfig {
|
||||
const result = this.list()
|
||||
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
|
||||
const defaultModel = defaultProvider?.models.find(
|
||||
(item) => item.id === defaultProvider.defaultModel
|
||||
)
|
||||
if (
|
||||
defaultProvider &&
|
||||
defaultModel &&
|
||||
(defaultProvider.hasApiKey || !needsApiKey(defaultProvider)) &&
|
||||
(defaultModel.capabilities.vision || defaultModel.capabilities.ocr)
|
||||
) {
|
||||
return {
|
||||
providerId: defaultProvider.id,
|
||||
providerName: defaultProvider.name,
|
||||
model: defaultModel.id,
|
||||
modelName: defaultModel.name || defaultModel.id,
|
||||
configured: true,
|
||||
status: defaultProvider.status,
|
||||
timeoutMs: defaultProvider.advanced.timeoutMs,
|
||||
source: 'default-model'
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of result.providers) {
|
||||
if (!provider.hasApiKey && needsApiKey(provider)) continue
|
||||
const model =
|
||||
provider.models.find(
|
||||
(item) =>
|
||||
item.id === provider.defaultModel && (item.capabilities.vision || item.capabilities.ocr)
|
||||
) || provider.models.find((item) => item.capabilities.vision || item.capabilities.ocr)
|
||||
if (!model) continue
|
||||
return {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model: model.id,
|
||||
modelName: model.name || model.id,
|
||||
configured: true,
|
||||
status: provider.status,
|
||||
timeoutMs: provider.advanced.timeoutMs,
|
||||
source: 'vision-capability'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providerName: '尚未配置',
|
||||
model: '',
|
||||
modelName: '尚未验证图片理解模型',
|
||||
configured: false,
|
||||
status: 'untested',
|
||||
source: 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus {
|
||||
const result = this.list()
|
||||
const provider =
|
||||
|
||||
@@ -80,7 +80,7 @@ const agentSystemPrompt = (
|
||||
question: string,
|
||||
scopeLabel: string,
|
||||
rangeLabel: string
|
||||
): string => `你是 WechatExplorer 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
|
||||
): string => `你是 TraceMemo 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
|
||||
用户问题:${question}
|
||||
允许范围:${scopeLabel};时间范围:${rangeLabel}。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, session } from 'electron'
|
||||
import { app, session, shell } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { clearBootstrapCache } from './bootstrap-cache'
|
||||
@@ -62,7 +62,8 @@ export function getCacheSummary(): CacheSummary {
|
||||
{
|
||||
id: 'knowledge',
|
||||
label: '本地知识库索引',
|
||||
description: '为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
|
||||
description:
|
||||
'为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
|
||||
...knowledge
|
||||
}
|
||||
]
|
||||
@@ -90,3 +91,13 @@ export async function clearCache(
|
||||
}
|
||||
return getCacheSummary()
|
||||
}
|
||||
|
||||
export async function openKnowledgeDirectory(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await fs.ensureDir(KNOWLEDGE_CACHE_DIR)
|
||||
const error = await shell.openPath(KNOWLEDGE_CACHE_DIR)
|
||||
return error ? { success: false, error } : { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
DatabaseKeyValidationCode,
|
||||
DatabaseKeyValidationResult
|
||||
} from '../../shared/database-key'
|
||||
import {
|
||||
isWindowsVcRuntimeMissingError,
|
||||
WINDOWS_VC_RUNTIME_ERROR_MESSAGE
|
||||
} from '../../shared/windows-runtime'
|
||||
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||
import type { ExportImageQuality } from '../../shared/image-quality'
|
||||
|
||||
@@ -297,7 +301,7 @@ function listSourceMessages(
|
||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||
? 49
|
||||
: msgType
|
||||
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
if (!isPatMessage && [3, 34, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
try {
|
||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||
const hasStickerPayload =
|
||||
@@ -675,11 +679,13 @@ const DATABASE_KEY_ERROR_MESSAGES: Record<DatabaseKeyValidationCode, string> = {
|
||||
ACCOUNT_MISMATCH: '密钥与当前账号不匹配',
|
||||
ROOT_UNAVAILABLE: '当前数据库目录不可用',
|
||||
DATABASE_FILE_MISSING: '数据库文件缺失',
|
||||
VC_RUNTIME_MISSING: WINDOWS_VC_RUNTIME_ERROR_MESSAGE,
|
||||
UNKNOWN_VALIDATION_ERROR: '未知验证错误'
|
||||
}
|
||||
|
||||
function mapConnectionError(detail: string): DatabaseKeyValidationCode {
|
||||
const normalized = detail.toLowerCase()
|
||||
if (isWindowsVcRuntimeMissingError(detail, process.platform)) return 'VC_RUNTIME_MISSING'
|
||||
if (normalized.includes('-1005') || normalized.includes('不匹配')) return 'ACCOUNT_MISMATCH'
|
||||
if (normalized.includes('session.db') || normalized.includes('数据库文件')) {
|
||||
return 'DATABASE_FILE_MISSING'
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function inspectImageDecryptionStatus(
|
||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||
const stickerCacheFound =
|
||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'TraceMemo', 'Emojis')) ||
|
||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
||||
const dbConnected = chat.isReady()
|
||||
const [wechatRunning, decoder] = await Promise.all([
|
||||
@@ -287,7 +288,7 @@ export function buildImageTestDiagnosticLog(input: {
|
||||
const rootIsDirectory = rootExists ? safeIsDirectory(root) : false
|
||||
const resultCode = input.result.success ? 'SUCCESS' : input.result.code || 'UNKNOWN'
|
||||
return [
|
||||
'WechatExplorer 图片解析测试日志(已脱敏)',
|
||||
'TraceMemo 图片解析测试日志(已脱敏)',
|
||||
`时间:${new Date().toISOString()}`,
|
||||
`应用版本:${safeAppVersion()}`,
|
||||
`运行环境:${process.platform} ${process.arch}`,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// src/main/services/image-insight-service.ts
|
||||
// WechatExplorer AI 图片理解基础设施
|
||||
// TraceMemo AI 图片理解基础设施
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
||||
// 2. 同图(imageHash)走缓存,绝不重复调 AI
|
||||
// 2. 同图(imageHash)在 10 分钟内走缓存,过期后重新调 AI
|
||||
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
|
||||
// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI
|
||||
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { randomUUID } from 'crypto'
|
||||
@@ -22,6 +22,11 @@ import type {
|
||||
ImageCandidateQuery,
|
||||
ImageInsight
|
||||
} from '../../shared/image-insight'
|
||||
import {
|
||||
calculateImageHeatScore,
|
||||
isFreshImageInsight,
|
||||
isHotImageCandidate
|
||||
} from '../../shared/image-insight'
|
||||
|
||||
/**
|
||||
* 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。
|
||||
@@ -42,6 +47,13 @@ export interface ImageCandidateInput {
|
||||
|
||||
interface ProviderServiceLike {
|
||||
list(): ProviderSummaryLike
|
||||
getVisionRuntimeConfig(): {
|
||||
providerId?: string
|
||||
providerName: string
|
||||
model: string
|
||||
modelName: string
|
||||
configured: boolean
|
||||
}
|
||||
analyzeImage(
|
||||
messages: Array<{
|
||||
role: string
|
||||
@@ -77,7 +89,7 @@ interface ProviderSummaryLike {
|
||||
class ImageInsightService {
|
||||
private providerService: ProviderServiceLike | null = null
|
||||
private decryptService: DecryptServiceLike | null = null
|
||||
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
|
||||
/** 最近一次实际使用的视觉 provider/model,仅用于写入分析元数据 */
|
||||
private runtimeProviderId: string | undefined = undefined
|
||||
private runtimeModelId: string | undefined = undefined
|
||||
|
||||
@@ -93,13 +105,11 @@ class ImageInsightService {
|
||||
this.runtimeProviderId,
|
||||
this.runtimeModelId
|
||||
)
|
||||
// 读取默认 provider/model(后续 analyze 时使用)
|
||||
// 读取当前视觉 provider/model(后续 analyze 时仍会刷新,避免配置变化后继续用旧模型)
|
||||
try {
|
||||
const list = deps.providerService.list()
|
||||
const provider =
|
||||
list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0]
|
||||
this.runtimeProviderId = provider?.id
|
||||
this.runtimeModelId = provider?.defaultModel
|
||||
const runtime = deps.providerService.getVisionRuntimeConfig()
|
||||
this.runtimeProviderId = runtime.providerId
|
||||
this.runtimeModelId = runtime.model || undefined
|
||||
console.log(
|
||||
'[ImageInsightService] bind loaded default provider=%s model=%s',
|
||||
this.runtimeProviderId,
|
||||
@@ -141,7 +151,7 @@ class ImageInsightService {
|
||||
|
||||
/**
|
||||
* 主入口:分析一张图片。
|
||||
* 1. 通过 imageHash 查缓存,命中即返回
|
||||
* 1. 通过 imageHash 查 10 分钟缓存,新鲜则返回
|
||||
* 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
|
||||
* 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
|
||||
*/
|
||||
@@ -149,8 +159,15 @@ class ImageInsightService {
|
||||
try {
|
||||
if (!request.force) {
|
||||
const cached = imageInsightsStore.getByHash(request.imageHash)
|
||||
if (isFreshImageInsight(cached)) {
|
||||
return { success: true, insight: cached || undefined, fromCache: true }
|
||||
}
|
||||
if (cached) {
|
||||
return { success: true, insight: cached, fromCache: true }
|
||||
console.log(
|
||||
'[ImageInsightService] cache expired hash=%s ageMs=%d',
|
||||
request.imageHash,
|
||||
Date.now() - Number(cached.updatedAt || 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,11 +197,24 @@ class ImageInsightService {
|
||||
}
|
||||
]
|
||||
|
||||
const list = this.providerService.list()
|
||||
const provider =
|
||||
list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0]
|
||||
this.runtimeProviderId = provider?.id
|
||||
this.runtimeModelId = provider?.defaultModel
|
||||
const runtime =
|
||||
request.providerId && request.modelId
|
||||
? {
|
||||
providerId: request.providerId,
|
||||
model: request.modelId,
|
||||
configured: true
|
||||
}
|
||||
: this.providerService.getVisionRuntimeConfig()
|
||||
if (!runtime.configured || !runtime.providerId || !runtime.model) {
|
||||
return { success: false, error: '尚未配置或验证支持图片理解的 AI 模型' }
|
||||
}
|
||||
this.runtimeProviderId = runtime.providerId
|
||||
this.runtimeModelId = runtime.model
|
||||
console.log(
|
||||
'[ImageInsightService] analyze using vision provider=%s model=%s',
|
||||
this.runtimeProviderId,
|
||||
this.runtimeModelId
|
||||
)
|
||||
const result = await this.providerService.analyzeImage(messages, {
|
||||
providerId: this.runtimeProviderId,
|
||||
modelId: this.runtimeModelId
|
||||
@@ -234,7 +264,7 @@ class ImageInsightService {
|
||||
query: ImageCandidateQuery,
|
||||
inputs: ImageCandidateInput[] = []
|
||||
): Promise<ImageCandidate[]> {
|
||||
const limit = query.limit ?? 3
|
||||
const limit = Math.min(3, Math.max(0, query.limit ?? 3))
|
||||
const candidates: ImageCandidate[] = []
|
||||
console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length)
|
||||
for (const input of inputs) {
|
||||
@@ -248,7 +278,16 @@ class ImageInsightService {
|
||||
)
|
||||
continue
|
||||
}
|
||||
const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1
|
||||
if (!isHotImageCandidate(input)) {
|
||||
console.log(
|
||||
'[ImageInsightService] skip %s: not hot (responses=%d interactions=%d)',
|
||||
input.messageId,
|
||||
input.responseCount,
|
||||
input.interactionCount
|
||||
)
|
||||
continue
|
||||
}
|
||||
const heatScore = calculateImageHeatScore(input)
|
||||
const candidate: ImageCandidate = {
|
||||
messageId: input.messageId,
|
||||
imageHash: hash,
|
||||
@@ -260,7 +299,7 @@ class ImageInsightService {
|
||||
heatScore
|
||||
}
|
||||
const cached = imageInsightsStore.getByHash(hash)
|
||||
if (cached) candidate.insight = cached
|
||||
if (isFreshImageInsight(cached) && cached) candidate.insight = cached
|
||||
candidates.push(candidate)
|
||||
}
|
||||
candidates.sort((a, b) => b.heatScore - a.heatScore)
|
||||
@@ -273,7 +312,6 @@ class ImageInsightService {
|
||||
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||
return imageInsightsStore.listBySession(sessionId, limit)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const imageInsightService = new ImageInsightService()
|
||||
|
||||
@@ -3,10 +3,12 @@ import { existsSync, promises as fs } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { isPackagedRuntime } from '../runtime-mode'
|
||||
|
||||
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||
const GITHUB_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
|
||||
const SKILL_VERSION = 'v1.1'
|
||||
const SKILL_RELATIVE_PATHS = [
|
||||
join('skill', 'tracememo-reader', 'SKILL.md'),
|
||||
join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||
]
|
||||
const GITHUB_URL = 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
|
||||
const SKILL_VERSION = 'v1.2'
|
||||
|
||||
type SkillResourceSource = 'development' | 'bundled'
|
||||
|
||||
@@ -54,19 +56,19 @@ function uniqueCandidates(candidates: SkillCandidate[]): SkillCandidate[] {
|
||||
|
||||
export function getSkillCandidates(environment?: SkillPathEnvironment): SkillCandidate[] {
|
||||
const runtime = environment || currentEnvironment()
|
||||
const developmentPaths = [
|
||||
join(runtime.appPath, 'docs', SKILL_RELATIVE_PATH),
|
||||
join(runtime.cwd, 'docs', SKILL_RELATIVE_PATH),
|
||||
join(dirname(runtime.appPath), 'docs', SKILL_RELATIVE_PATH)
|
||||
]
|
||||
const developmentPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
|
||||
join(runtime.appPath, 'docs', relativePath),
|
||||
join(runtime.cwd, 'docs', relativePath),
|
||||
join(dirname(runtime.appPath), 'docs', relativePath)
|
||||
])
|
||||
const execDirectory = dirname(runtime.execPath)
|
||||
const bundledPaths = [
|
||||
join(runtime.resourcesPath, SKILL_RELATIVE_PATH),
|
||||
join(runtime.resourcesPath, 'resources', SKILL_RELATIVE_PATH),
|
||||
join(dirname(runtime.appPath), SKILL_RELATIVE_PATH),
|
||||
join(execDirectory, 'resources', SKILL_RELATIVE_PATH),
|
||||
join(dirname(execDirectory), 'Resources', SKILL_RELATIVE_PATH)
|
||||
]
|
||||
const bundledPaths = SKILL_RELATIVE_PATHS.flatMap((relativePath) => [
|
||||
join(runtime.resourcesPath, relativePath),
|
||||
join(runtime.resourcesPath, 'resources', relativePath),
|
||||
join(dirname(runtime.appPath), relativePath),
|
||||
join(execDirectory, 'resources', relativePath),
|
||||
join(dirname(execDirectory), 'Resources', relativePath)
|
||||
])
|
||||
return uniqueCandidates(
|
||||
runtime.packaged
|
||||
? bundledPaths.map((path) => ({ path, source: 'bundled' }))
|
||||
@@ -90,7 +92,7 @@ export function resolveSkillResourceStatus(
|
||||
available: false,
|
||||
source,
|
||||
githubUrl: GITHUB_URL,
|
||||
error: `未找到 WechatExplorer Reader Skill 文件(已检查:${candidates.map((item) => item.path).join(';')})`
|
||||
error: `未找到 TraceMemo Reader Skill 文件(已检查:${candidates.map((item) => item.path).join(';')})`
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -19,9 +19,12 @@ const downloadCache = new Map<string, Promise<StickerResult>>()
|
||||
|
||||
export class StickerService {
|
||||
private readonly cacheDir: string
|
||||
private readonly legacyCacheDir: string
|
||||
|
||||
constructor(private readonly wcdb4Client?: Wcdb4Client | null) {
|
||||
this.cacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
||||
this.cacheDir = path.join(os.homedir(), 'Documents', 'TraceMemo', 'Emojis')
|
||||
// Keep reading the former directory so existing sticker caches remain usable.
|
||||
this.legacyCacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
||||
}
|
||||
|
||||
async resolveSticker(cdnUrl?: string, md5?: string): Promise<StickerResult> {
|
||||
@@ -69,7 +72,7 @@ export class StickerService {
|
||||
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||
const cacheDirs = [
|
||||
this.cacheDir,
|
||||
path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
||||
this.legacyCacheDir
|
||||
]
|
||||
for (const cacheDir of cacheDirs) {
|
||||
for (const ext of extensions) {
|
||||
@@ -128,7 +131,7 @@ export class StickerService {
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 MicroMessenger WechatExplorer',
|
||||
'User-Agent': 'Mozilla/5.0 MicroMessenger TraceMemo',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PcmProcessorOptions {
|
||||
}
|
||||
|
||||
export class PcmAudioProcessor implements AudioProcessor {
|
||||
readonly version = VOICE_PROCESSOR_VERSION
|
||||
private readonly targetSampleRate: number
|
||||
private readonly silenceThreshold: number
|
||||
private readonly silencePaddingMs: number
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
import { dirname } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type {
|
||||
TranscriptMessageStatus,
|
||||
TranscriptRecord,
|
||||
TranscriptRepository
|
||||
} from './types'
|
||||
import type { TranscriptMessageStatus, TranscriptRecord, TranscriptRepository } from './types'
|
||||
|
||||
type TranscriptKey = Omit<
|
||||
TranscriptRecord,
|
||||
'transcript' | 'language' | 'durationMs' | 'createdAt' | 'updatedAt'
|
||||
>
|
||||
|
||||
type CompatibleTranscriptKey = Pick<
|
||||
TranscriptRecord,
|
||||
| 'accountId'
|
||||
| 'messageIdentity'
|
||||
| 'processorVersion'
|
||||
| 'recognizerId'
|
||||
| 'modelVersion'
|
||||
| 'modelFingerprint'
|
||||
>
|
||||
|
||||
function transcriptRecord(row: Record<string, unknown>): TranscriptRecord {
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
messageIdentity: String(row.message_identity),
|
||||
audioHash: String(row.audio_hash),
|
||||
processorVersion: String(row.processor_version),
|
||||
recognizerId: String(row.recognizer_id),
|
||||
modelVersion: String(row.model_version),
|
||||
modelFingerprint: String(row.model_fingerprint),
|
||||
transcript: String(row.transcript),
|
||||
language: row.language ? String(row.language) : undefined,
|
||||
durationMs: Number(row.duration_ms),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteTranscriptRepository implements TranscriptRepository {
|
||||
private readonly database: DatabaseSync
|
||||
|
||||
@@ -70,20 +93,7 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
|
||||
key.modelFingerprint
|
||||
) as Record<string, unknown> | undefined
|
||||
if (!row) return null
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
messageIdentity: String(row.message_identity),
|
||||
audioHash: String(row.audio_hash),
|
||||
processorVersion: String(row.processor_version),
|
||||
recognizerId: String(row.recognizer_id),
|
||||
modelVersion: String(row.model_version),
|
||||
modelFingerprint: String(row.model_fingerprint),
|
||||
transcript: String(row.transcript),
|
||||
language: row.language ? String(row.language) : undefined,
|
||||
durationMs: Number(row.duration_ms),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
}
|
||||
return transcriptRecord(row)
|
||||
}
|
||||
|
||||
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null {
|
||||
@@ -99,19 +109,86 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
|
||||
)
|
||||
.get(accountId, messageIdentity) as Record<string, unknown> | undefined
|
||||
if (!row) return null
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
messageIdentity: String(row.message_identity),
|
||||
audioHash: String(row.audio_hash),
|
||||
processorVersion: String(row.processor_version),
|
||||
recognizerId: String(row.recognizer_id),
|
||||
modelVersion: String(row.model_version),
|
||||
modelFingerprint: String(row.model_fingerprint),
|
||||
transcript: String(row.transcript),
|
||||
language: row.language ? String(row.language) : undefined,
|
||||
durationMs: Number(row.duration_ms),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
return transcriptRecord(row)
|
||||
}
|
||||
|
||||
findCompatible(key: CompatibleTranscriptKey): TranscriptRecord | null {
|
||||
const row = this.database
|
||||
.prepare(
|
||||
`SELECT account_id, message_identity, audio_hash, processor_version,
|
||||
recognizer_id, model_version, model_fingerprint, transcript,
|
||||
language, duration_ms, created_at, updated_at
|
||||
FROM voice_transcripts
|
||||
WHERE account_id = ? AND message_identity = ? AND processor_version = ?
|
||||
AND recognizer_id = ? AND model_version = ? AND model_fingerprint = ?
|
||||
AND trim(transcript) <> ''
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(
|
||||
key.accountId,
|
||||
key.messageIdentity,
|
||||
key.processorVersion,
|
||||
key.recognizerId,
|
||||
key.modelVersion,
|
||||
key.modelFingerprint
|
||||
) as Record<string, unknown> | undefined
|
||||
return row ? transcriptRecord(row) : null
|
||||
}
|
||||
|
||||
mergeFrom(databasePath: string): number {
|
||||
this.database.prepare('ATTACH DATABASE ? AS legacy_voice').run(databasePath)
|
||||
try {
|
||||
const hasTranscripts = this.database
|
||||
.prepare(
|
||||
`SELECT 1 FROM legacy_voice.sqlite_master
|
||||
WHERE type = 'table' AND name = 'voice_transcripts'`
|
||||
)
|
||||
.get()
|
||||
if (!hasTranscripts)
|
||||
throw new Error('Legacy voice transcript database has no transcript table')
|
||||
|
||||
this.database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.database.exec(`
|
||||
INSERT OR IGNORE INTO main.voice_transcripts (
|
||||
account_id, message_identity, audio_hash, processor_version,
|
||||
recognizer_id, model_version, model_fingerprint, transcript,
|
||||
language, duration_ms, created_at, updated_at
|
||||
)
|
||||
SELECT account_id, message_identity, audio_hash, processor_version,
|
||||
recognizer_id, model_version, model_fingerprint, transcript,
|
||||
language, duration_ms, created_at, updated_at
|
||||
FROM legacy_voice.voice_transcripts
|
||||
WHERE trim(transcript) <> ''
|
||||
`)
|
||||
const inserted = Number(
|
||||
(
|
||||
this.database.prepare('SELECT changes() AS count').get() as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.count || 0
|
||||
)
|
||||
this.database.exec(`
|
||||
INSERT INTO main.voice_transcript_message_states (
|
||||
account_id, message_identity, state, error, updated_at
|
||||
)
|
||||
SELECT account_id, message_identity, 'transcribed', NULL, MAX(updated_at)
|
||||
FROM legacy_voice.voice_transcripts
|
||||
WHERE trim(transcript) <> ''
|
||||
GROUP BY account_id, message_identity
|
||||
ON CONFLICT (account_id, message_identity) DO UPDATE SET
|
||||
state = 'transcribed', error = NULL, updated_at = excluded.updated_at
|
||||
WHERE excluded.updated_at > voice_transcript_message_states.updated_at
|
||||
`)
|
||||
this.database.exec('COMMIT')
|
||||
return inserted
|
||||
} catch (error) {
|
||||
this.database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
this.database.exec('DETACH DATABASE legacy_voice')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export class SpeechRecognizerRegistry {
|
||||
}
|
||||
|
||||
export interface AudioProcessor {
|
||||
readonly version: string
|
||||
process(input: {
|
||||
pcm: Buffer
|
||||
sampleRate: number
|
||||
@@ -87,6 +88,17 @@ export interface TranscriptRepository {
|
||||
>
|
||||
): TranscriptRecord | null
|
||||
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null
|
||||
findCompatible(
|
||||
key: Pick<
|
||||
TranscriptRecord,
|
||||
| 'accountId'
|
||||
| 'messageIdentity'
|
||||
| 'processorVersion'
|
||||
| 'recognizerId'
|
||||
| 'modelVersion'
|
||||
| 'modelFingerprint'
|
||||
>
|
||||
): TranscriptRecord | null
|
||||
getMessageStatus(accountId: string, messageIdentity: string): TranscriptMessageStatus
|
||||
save(record: TranscriptRecord): void
|
||||
markFailure(accountId: string, messageIdentity: string, error: string): void
|
||||
|
||||
@@ -39,13 +39,28 @@ export class VoicePipeline {
|
||||
reference: VoiceMessageReference,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ transcript: string; language?: string; durationMs: number; cached: boolean }> {
|
||||
const messageIdentity = voiceMessageIdentity(reference)
|
||||
const compatible = this.transcripts.findCompatible({
|
||||
accountId,
|
||||
messageIdentity,
|
||||
processorVersion: this.audioProcessor.version,
|
||||
...this.recognizer.metadata
|
||||
})
|
||||
if (compatible?.transcript.trim()) {
|
||||
return {
|
||||
transcript: compatible.transcript.trim(),
|
||||
language: compatible.language,
|
||||
durationMs: compatible.durationMs,
|
||||
cached: true
|
||||
}
|
||||
}
|
||||
|
||||
const source = await this.sourceResolver.resolve(reference)
|
||||
if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
const decoded = await this.decoderRegistry.decode(source)
|
||||
if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
const audio = this.audioProcessor.process(decoded)
|
||||
if (audio.samples.length === 0) throw new Error('Voice audio is empty after processing')
|
||||
const messageIdentity = voiceMessageIdentity(reference)
|
||||
const key = {
|
||||
accountId,
|
||||
messageIdentity,
|
||||
|
||||
@@ -97,36 +97,44 @@ export class VoiceRecognitionUseCase {
|
||||
const generation = this.accountGeneration
|
||||
const accountIdentity = this.accountId
|
||||
return this.scheduler
|
||||
.schedule(key, async (signal) => {
|
||||
const status = await this.modelManager.getStatus()
|
||||
if (status.state !== 'ready') {
|
||||
return { success: false, code: 'MODEL_NOT_READY', error: '请先下载语音识别模型' } as const
|
||||
}
|
||||
const result = await pipeline.run(accountId, reference, signal)
|
||||
if (signal.aborted || !this.isCurrentAccount(accountId, generation)) {
|
||||
throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
}
|
||||
const transcript = result.transcript.trim()
|
||||
if (
|
||||
transcript &&
|
||||
options?.publishTranscriptUpdate !== false &&
|
||||
this.isCurrentAccount(accountId, generation)
|
||||
) {
|
||||
try {
|
||||
await this.publishTranscriptUpdate({
|
||||
accountIdentity,
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: 'transcribed',
|
||||
transcript,
|
||||
cached: result.cached
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[Voice] transcript indexed asynchronously failed:', error)
|
||||
.schedule(
|
||||
key,
|
||||
async (signal) => {
|
||||
const status = await this.modelManager.getStatus()
|
||||
if (status.state !== 'ready') {
|
||||
return {
|
||||
success: false,
|
||||
code: 'MODEL_NOT_READY',
|
||||
error: '请先下载语音识别模型'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
return { success: true, ...result, transcript } as const
|
||||
}, { priority: options?.priority })
|
||||
const result = await pipeline.run(accountId, reference, signal)
|
||||
if (signal.aborted || !this.isCurrentAccount(accountId, generation)) {
|
||||
throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
}
|
||||
const transcript = result.transcript.trim()
|
||||
if (
|
||||
transcript &&
|
||||
options?.publishTranscriptUpdate !== false &&
|
||||
this.isCurrentAccount(accountId, generation)
|
||||
) {
|
||||
try {
|
||||
await this.publishTranscriptUpdate({
|
||||
accountIdentity,
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: 'transcribed',
|
||||
transcript,
|
||||
cached: result.cached
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[Voice] transcript indexed asynchronously failed:', error)
|
||||
}
|
||||
}
|
||||
return { success: true, ...result, transcript } as const
|
||||
},
|
||||
{ priority: options?.priority }
|
||||
)
|
||||
.catch((error): VoiceRecognitionResult => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return { success: false, code: 'CANCELLED', error: '语音识别已取消' }
|
||||
@@ -173,18 +181,25 @@ export class VoiceRecognitionUseCase {
|
||||
}
|
||||
|
||||
async publishTranscriptSnapshot(reference: VoiceMessageReference): Promise<void> {
|
||||
const accountIdentity = this.accountId
|
||||
if (!accountIdentity) return
|
||||
const snapshot = this.getTranscriptSnapshot(reference)
|
||||
if (snapshot.state === 'pending') return
|
||||
await this.publishTranscript(reference, snapshot.transcript, snapshot.state === 'transcribed')
|
||||
}
|
||||
|
||||
async publishTranscript(
|
||||
reference: VoiceMessageReference,
|
||||
transcript?: string,
|
||||
cached = true
|
||||
): Promise<void> {
|
||||
const accountIdentity = this.accountId
|
||||
if (!accountIdentity || !transcript?.trim()) return
|
||||
await this.publishTranscriptUpdate({
|
||||
accountIdentity,
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: snapshot.state,
|
||||
transcript: snapshot.transcript,
|
||||
error: snapshot.error,
|
||||
cached: snapshot.state === 'transcribed'
|
||||
state: 'transcribed',
|
||||
transcript: transcript.trim(),
|
||||
cached
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ export type ResolveSourceResult =
|
||||
| { success: true; source: EncodedVoiceSource }
|
||||
| { success: false; error: string }
|
||||
|
||||
export interface VoiceReference {
|
||||
sessionId: string
|
||||
localId: number
|
||||
createTime: number
|
||||
svrId?: string | number
|
||||
}
|
||||
|
||||
export class VoiceService {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private voiceCache = new Map<string, string>()
|
||||
@@ -70,6 +77,83 @@ export class VoiceService {
|
||||
return { success: true, data: base64Data }
|
||||
}
|
||||
|
||||
async resolveVoices(
|
||||
references: VoiceReference[]
|
||||
): Promise<Array<{ success: boolean; data?: string; error?: string }>> {
|
||||
const results: Array<{ success: boolean; data?: string; error?: string } | undefined> =
|
||||
new Array(references.length)
|
||||
const missing: Array<{ index: number; reference: VoiceReference }> = []
|
||||
references.forEach((reference, index) => {
|
||||
const cached = this.voiceCache.get(
|
||||
this.buildCacheKey(reference.sessionId, reference.localId, reference.createTime)
|
||||
)
|
||||
if (cached) results[index] = { success: true, data: cached }
|
||||
else missing.push({ index, reference })
|
||||
})
|
||||
if (!missing.length)
|
||||
return results as Array<{ success: boolean; data?: string; error?: string }>
|
||||
|
||||
const sources = await this.wcdb4Client.getVoiceDataBatch(
|
||||
missing.map(({ reference }) => ({
|
||||
sessionId: reference.sessionId,
|
||||
createTime: reference.createTime,
|
||||
localId: reference.localId,
|
||||
svrId: reference.svrId,
|
||||
candidates: this.buildCandidates(reference.sessionId)
|
||||
}))
|
||||
)
|
||||
const failed: Array<{ index: number; reference: VoiceReference }> = []
|
||||
for (const [{ index, reference }, source] of missing.map(
|
||||
(item, sourceIndex) => [item, sources[sourceIndex]] as const
|
||||
)) {
|
||||
if (!source?.success || !source.hex) {
|
||||
failed.push({ index, reference })
|
||||
continue
|
||||
}
|
||||
const silkData = this.decodeVoiceBlob(source.hex)
|
||||
if (!silkData?.length) {
|
||||
results[index] = { success: false, error: '语音数据为空' }
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const decoded = await this.decoderRegistry.decode({
|
||||
data: silkData,
|
||||
codec: 'silk',
|
||||
sourceHash: createHash('sha256').update(silkData).digest('hex')
|
||||
})
|
||||
const wavData = this.createWavBuffer(decoded.pcm, 24000)
|
||||
const data = wavData.toString('base64')
|
||||
const cacheKey = this.buildCacheKey(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime
|
||||
)
|
||||
this.voiceCache.set(cacheKey, data)
|
||||
this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' })
|
||||
results[index] = { success: true, data }
|
||||
} catch (error) {
|
||||
results[index] = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Silk 解码失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
const retried = await Promise.all(
|
||||
failed.map(({ reference }) =>
|
||||
this.resolveVoice(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime,
|
||||
reference.svrId
|
||||
)
|
||||
)
|
||||
)
|
||||
failed.forEach(({ index }, retryIndex) => {
|
||||
results[index] = retried[retryIndex]
|
||||
})
|
||||
return results as Array<{ success: boolean; data?: string; error?: string }>
|
||||
}
|
||||
|
||||
async resolvePcm(
|
||||
sessionId: string,
|
||||
localId: number,
|
||||
|
||||
+319
-163
@@ -58,6 +58,17 @@ export interface Wcdb4GroupMember {
|
||||
m_nsHeadImgUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 联系人表中独立保存的成员名称字段。
|
||||
*
|
||||
* 群成员接口的 nickname 在部分微信数据版本中会被通讯录备注覆盖,因此不能用它
|
||||
* 推断微信昵称或备注。
|
||||
*/
|
||||
type Wcdb4ContactMemberNames = {
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface Wcdb4ImageHardlink {
|
||||
file_name?: string
|
||||
full_path?: string
|
||||
@@ -239,6 +250,20 @@ type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
|
||||
type WcdbVoidOut = [unknown]
|
||||
type WcdbHandleOut = [number]
|
||||
|
||||
export interface Wcdb4VoiceDataRequest {
|
||||
sessionId: string
|
||||
createTime: number
|
||||
candidates: string[]
|
||||
localId?: number
|
||||
svrId?: string | number
|
||||
}
|
||||
|
||||
export interface Wcdb4VoiceDataResult {
|
||||
success: boolean
|
||||
hex?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
function isAsciiPath(value: string): boolean {
|
||||
@@ -261,20 +286,38 @@ export function resolveWindowsNativeAccountRoot(
|
||||
.find((candidate) => candidate && isAsciiPath(candidate))
|
||||
if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot
|
||||
|
||||
const bridgeRoot = path.join(publicRoot, 'WechatExplorer', 'path-bridges')
|
||||
const bridgePath = path.join(
|
||||
bridgeRoot,
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(path.resolve(accountRoot).toLowerCase())
|
||||
.digest('hex')
|
||||
.slice(0, 24)
|
||||
)
|
||||
const bridgeId = crypto
|
||||
.createHash('sha256')
|
||||
.update(path.resolve(accountRoot).toLowerCase())
|
||||
.digest('hex')
|
||||
.slice(0, 24)
|
||||
const legacyBridgePath = path.join(publicRoot, 'WechatExplorer', 'path-bridges', bridgeId)
|
||||
try {
|
||||
if (fs.existsSync(legacyBridgePath)) {
|
||||
const legacyTarget = fs.realpathSync.native(legacyBridgePath)
|
||||
const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
|
||||
if (
|
||||
path.resolve(legacyTarget).toLowerCase() ===
|
||||
path.resolve(canonicalAccountRoot).toLowerCase()
|
||||
) {
|
||||
return legacyBridgePath
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An unreadable legacy junction is left untouched; TraceMemo creates its own bridge below.
|
||||
}
|
||||
|
||||
const bridgeRoot = path.join(publicRoot, 'TraceMemo', 'path-bridges')
|
||||
const bridgePath = path.join(bridgeRoot, bridgeId)
|
||||
try {
|
||||
fs.ensureDirSync(bridgeRoot)
|
||||
if (fs.existsSync(bridgePath)) {
|
||||
const existingTarget = fs.realpathSync.native(bridgePath)
|
||||
if (path.resolve(existingTarget).toLowerCase() === path.resolve(accountRoot).toLowerCase()) {
|
||||
const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
|
||||
if (
|
||||
path.resolve(existingTarget).toLowerCase() ===
|
||||
path.resolve(canonicalAccountRoot).toLowerCase()
|
||||
) {
|
||||
return bridgePath
|
||||
}
|
||||
fs.removeSync(bridgePath)
|
||||
@@ -380,6 +423,7 @@ export class Wcdb4Client {
|
||||
outHex: WcdbVoidOut
|
||||
) => number)
|
||||
| null = null
|
||||
private wcdbGetVoiceDataBatch: KoffiAsyncFunction | null = null
|
||||
private wcdbResolveImageHardlink:
|
||||
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
@@ -1684,70 +1728,16 @@ export class Wcdb4Client {
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
|
||||
)
|
||||
|
||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const username = this.pickString(row, [
|
||||
'username',
|
||||
'userName',
|
||||
'user_name',
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const wechatNickname = this.pickString(row, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'wechatNickname',
|
||||
'wechat_nickname',
|
||||
'm_nsNickName'
|
||||
])
|
||||
const remark = this.pickString(row, [
|
||||
'remark',
|
||||
'remarkName',
|
||||
'remark_name',
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||
const avatar = this.pickString(row, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'headImgUrl',
|
||||
'm_nsHeadImgUrl'
|
||||
])
|
||||
|
||||
if (username) {
|
||||
if (avatar) this.avatarCache.set(username, avatar)
|
||||
}
|
||||
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||
groupNickname: groupNicknames.get(username) || '',
|
||||
wechatNickname: wechatNickname || memberNickname,
|
||||
remark,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
|
||||
const missingDisplayNames = members
|
||||
.filter((member) => !member.nickname)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
this.hydrateDisplayNames(missingDisplayNames)
|
||||
const memberRows = Array.isArray(rows) ? rows : []
|
||||
const contactNames = this.readContactMemberNames(this.groupMemberUsernames(memberRows))
|
||||
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
|
||||
this.hydrateAvatarUrls(
|
||||
members
|
||||
.filter((member) => !member.m_nsHeadImgUrl)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
)
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
nickname:
|
||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||
wechatNickname:
|
||||
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
return this.withCachedGroupMemberAvatars(members)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -1821,70 +1811,98 @@ export class Wcdb4Client {
|
||||
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
||||
chatroomId
|
||||
)
|
||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const username = this.pickString(row, [
|
||||
const memberRows = Array.isArray(rows) ? rows : []
|
||||
const contactNames = await this.readContactMemberNamesAsync(
|
||||
this.groupMemberUsernames(memberRows)
|
||||
)
|
||||
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
|
||||
const missingAvatars = members
|
||||
.filter((member) => !member.m_nsHeadImgUrl)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
await this.hydrateAvatarUrlsAsync(missingAvatars)
|
||||
return this.withCachedGroupMemberAvatars(members)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将群成员接口与联系人表组装为语义明确的成员快照。
|
||||
*
|
||||
* 群昵称只接受群聊数据;微信昵称与通讯录备注只接受 contact 表。即使群成员
|
||||
* 接口返回了 nickname,也不能作为名称字段的回退,以免把通讯录备注误展示为微信昵称。
|
||||
*/
|
||||
private normalizeGroupMembers(
|
||||
rows: Record<string, unknown>[],
|
||||
groupNicknames: Map<string, string>,
|
||||
contactNames: Map<string, Wcdb4ContactMemberNames>
|
||||
): Wcdb4GroupMember[] {
|
||||
return rows.map((row) => {
|
||||
const username = this.pickString(row, [
|
||||
'username',
|
||||
'userName',
|
||||
'user_name',
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const rowGroupNickname = this.pickString(row, [
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'displayName',
|
||||
'display_name'
|
||||
])
|
||||
const groupNickname = groupNicknames.get(username) || rowGroupNickname
|
||||
const contact = contactNames.get(username)
|
||||
const wechatNickname = contact?.wechatNickname || ''
|
||||
const remark = contact?.remark || ''
|
||||
const avatar = this.pickString(row, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'headImgUrl',
|
||||
'm_nsHeadImgUrl'
|
||||
])
|
||||
|
||||
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
// nickname 是旧调用方使用的通用显示字段,保持安全的名称优先级。
|
||||
nickname: wechatNickname || groupNickname || username,
|
||||
groupNickname,
|
||||
wechatNickname,
|
||||
remark,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从原始群成员行提取联系人查询键,避免把空值传进 SQL IN 条件。
|
||||
*/
|
||||
private groupMemberUsernames(rows: Record<string, unknown>[]): string[] {
|
||||
return rows
|
||||
.map((row) =>
|
||||
this.pickString(row, [
|
||||
'username',
|
||||
'userName',
|
||||
'user_name',
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const wechatNickname = this.pickString(row, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'wechatNickname',
|
||||
'wechat_nickname',
|
||||
'm_nsNickName'
|
||||
])
|
||||
const remark = this.pickString(row, [
|
||||
'remark',
|
||||
'remarkName',
|
||||
'remark_name',
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||
const avatar = this.pickString(row, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'headImgUrl',
|
||||
'm_nsHeadImgUrl'
|
||||
])
|
||||
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||
groupNickname: groupNicknames.get(username) || '',
|
||||
wechatNickname: wechatNickname || memberNickname,
|
||||
remark,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const missingNames = members
|
||||
.filter((member) => !member.nickname)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
const missingAvatars = members
|
||||
.filter((member) => !member.m_nsHeadImgUrl)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
await Promise.all([
|
||||
this.hydrateDisplayNamesAsync(missingNames),
|
||||
this.hydrateAvatarUrlsAsync(missingAvatars)
|
||||
])
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
nickname:
|
||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||
wechatNickname:
|
||||
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
|
||||
return []
|
||||
}
|
||||
/**
|
||||
* 为已标准化的成员补充头像缓存,不参与名称字段的回退。
|
||||
*/
|
||||
private withCachedGroupMemberAvatars(members: Wcdb4GroupMember[]): Wcdb4GroupMember[] {
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
}
|
||||
|
||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||
@@ -1950,32 +1968,104 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
const handle = this.ensureHandle()
|
||||
const outHex: WcdbVoidOut = [null]
|
||||
|
||||
try {
|
||||
const result = this.wcdbGetVoiceData(
|
||||
const fn = this.wcdbGetVoiceData as unknown as KoffiAsyncFunction
|
||||
return this.createTrackedNativeCall<Wcdb4VoiceDataResult>((resolve, reject) => {
|
||||
const outHex: WcdbVoidOut = [null]
|
||||
fn.async(
|
||||
handle,
|
||||
sessionId,
|
||||
createTime,
|
||||
localId,
|
||||
BigInt(svrId || 0),
|
||||
JSON.stringify(candidates),
|
||||
outHex
|
||||
outHex,
|
||||
(error: unknown, code: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = Number(code)
|
||||
if (result !== 0 || !outHex[0]) {
|
||||
resolve({ success: false, error: `获取语音数据失败: ${result}` })
|
||||
return
|
||||
}
|
||||
const hex = this.decodeHexPtr(outHex[0])
|
||||
resolve(
|
||||
hex === null
|
||||
? { success: false, error: '解析语音数据失败' }
|
||||
: { success: true, hex, error: '' }
|
||||
)
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outHex[0])
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (result !== 0 || !outHex[0]) {
|
||||
return { success: false, error: `获取语音数据失败: ${result}` }
|
||||
}
|
||||
|
||||
const hex = this.decodeHexPtr(outHex[0])
|
||||
if (hex === null) {
|
||||
return { success: false, error: '解析语音数据失败' }
|
||||
}
|
||||
|
||||
return { success: true, hex, error: '' }
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outHex[0])
|
||||
async getVoiceDataBatch(requests: Wcdb4VoiceDataRequest[]): Promise<Wcdb4VoiceDataResult[]> {
|
||||
if (!requests.length) return []
|
||||
if (!this.wcdbGetVoiceDataBatch) {
|
||||
return this.getVoiceDataIndividually(requests)
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbGetVoiceDataBatch,
|
||||
JSON.stringify(
|
||||
requests.map((request, index) => ({
|
||||
session_id: request.sessionId,
|
||||
create_time: request.createTime,
|
||||
local_id: request.localId || 0,
|
||||
svr_id: String(request.svrId || 0),
|
||||
candidates: request.candidates,
|
||||
index
|
||||
}))
|
||||
)
|
||||
)
|
||||
const byIndex = new Map(
|
||||
(Array.isArray(rows) ? rows : []).map((row) => [Number(row.index), row])
|
||||
)
|
||||
return requests.map((_, index) => {
|
||||
const row = byIndex.get(index)
|
||||
const rawHex = row?.hex ?? row?.data
|
||||
const rawSuccess = row?.success ?? row?.Success
|
||||
const hex = typeof rawHex === 'string' ? rawHex : undefined
|
||||
const success = (rawSuccess === true || Number(rawSuccess) === 1) && Boolean(hex)
|
||||
return {
|
||||
success,
|
||||
hex: success ? hex : undefined,
|
||||
error:
|
||||
typeof row?.error === 'string' && row.error
|
||||
? row.error
|
||||
: success
|
||||
? ''
|
||||
: '获取语音数据失败'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] batch voice lookup failed, using single-item fallback:', error)
|
||||
return this.getVoiceDataIndividually(requests)
|
||||
}
|
||||
}
|
||||
|
||||
private async getVoiceDataIndividually(
|
||||
requests: Wcdb4VoiceDataRequest[]
|
||||
): Promise<Wcdb4VoiceDataResult[]> {
|
||||
const results: Wcdb4VoiceDataResult[] = []
|
||||
for (const request of requests) {
|
||||
results.push(
|
||||
await this.getVoiceData(
|
||||
request.sessionId,
|
||||
request.createTime,
|
||||
request.candidates,
|
||||
request.localId,
|
||||
request.svrId
|
||||
)
|
||||
)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private decodeHexPtr(ptr: unknown): string | null {
|
||||
@@ -2276,6 +2366,14 @@ export class Wcdb4Client {
|
||||
this.wcdbGetVoiceData = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetVoiceDataBatch = lib.func(
|
||||
'int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)'
|
||||
)
|
||||
} catch {
|
||||
this.wcdbGetVoiceDataBatch = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbResolveImageHardlink = lib.func(
|
||||
'int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)'
|
||||
@@ -2655,27 +2753,6 @@ export class Wcdb4Client {
|
||||
return false
|
||||
}
|
||||
|
||||
private hydrateDisplayNames(usernames: string[]): void {
|
||||
if (!this.wcdbGetDisplayNames) return
|
||||
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
|
||||
try {
|
||||
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
|
||||
(handle, outJson) => this.wcdbGetDisplayNames!(handle, JSON.stringify(missing), outJson)
|
||||
)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'remark',
|
||||
'name'
|
||||
]).forEach((name, username) => this.displayNameCache.set(username, name))
|
||||
} catch {
|
||||
// Names are optional; usernames are still enough to load chats.
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
|
||||
if (!this.wcdbGetDisplayNames) return
|
||||
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||
@@ -2781,6 +2858,85 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 contact 表读取群成员的微信昵称与通讯录备注。
|
||||
*
|
||||
* 查询失败时返回空映射:群成员快照仍可用,并由调用方回退到群昵称或 wxid;
|
||||
* 不得使用群成员接口的 nickname,因为该字段可能实际保存的是通讯录备注。
|
||||
*/
|
||||
private readContactMemberNames(usernames: string[]): Map<string, Wcdb4ContactMemberNames> {
|
||||
const result = new Map<string, Wcdb4ContactMemberNames>()
|
||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||
|
||||
const inList = this.uniq(usernames)
|
||||
.map((username) => `'${username.replace(/'/g, "''")}'`)
|
||||
.join(',')
|
||||
if (!inList) return result
|
||||
|
||||
try {
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbExecQuery!(
|
||||
handle,
|
||||
'contact',
|
||||
'',
|
||||
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`,
|
||||
outJson
|
||||
)
|
||||
)
|
||||
return this.contactMemberNamesFromRows(rows)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步读取 contact 表的成员名称字段,语义与同步路径保持一致。
|
||||
*/
|
||||
private async readContactMemberNamesAsync(
|
||||
usernames: string[]
|
||||
): Promise<Map<string, Wcdb4ContactMemberNames>> {
|
||||
const result = new Map<string, Wcdb4ContactMemberNames>()
|
||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||
|
||||
const inList = this.uniq(usernames)
|
||||
.map((username) => `'${username.replace(/'/g, "''")}'`)
|
||||
.join(',')
|
||||
if (!inList) return result
|
||||
|
||||
try {
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
||||
'contact',
|
||||
'',
|
||||
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`
|
||||
)
|
||||
return this.contactMemberNamesFromRows(rows)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 contact 查询行转换为按 wxid 索引的名称映射。
|
||||
*/
|
||||
private contactMemberNamesFromRows(
|
||||
rows: Record<string, unknown>[]
|
||||
): Map<string, Wcdb4ContactMemberNames> {
|
||||
const result = new Map<string, Wcdb4ContactMemberNames>()
|
||||
if (!Array.isArray(rows)) return result
|
||||
|
||||
for (const row of rows) {
|
||||
const username = this.pickString(row, ['username', 'user_name', 'userName', 'm_nsUsrName'])
|
||||
if (!username) continue
|
||||
result.set(username, {
|
||||
wechatNickname: this.pickString(row, ['nick_name', 'nickName', 'm_nsNickName']),
|
||||
remark: this.pickString(row, ['remark', 'remark_name', 'remarkName'])
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
||||
const result = new Map<string, string>()
|
||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||
|
||||
+5
-11
@@ -216,17 +216,11 @@ export class WechatDb {
|
||||
if (!username) return []
|
||||
if (startTime && endTime && startTime > endTime) return []
|
||||
|
||||
// A bounded native cursor can omit rows stored across a message shard boundary.
|
||||
// Scan without bounds first, then apply the requested range in application code.
|
||||
const rows = await this.wcdb4Client.getMessagesAsync(username)
|
||||
const messages = rows
|
||||
.map((message) => ({ ...message.raw, ...message }))
|
||||
.filter((message) => {
|
||||
const createTime = Number(message.msgCreateTime || 0)
|
||||
if (startTime && createTime < startTime) return false
|
||||
if (endTime && createTime > endTime) return false
|
||||
return true
|
||||
})
|
||||
// The async client merges cursor and shard-table results while applying the
|
||||
// time range in SQLite. Avoid loading the entire conversation when exporting
|
||||
// a narrow range; this is especially important for chats with long history.
|
||||
const rows = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime)
|
||||
const messages = rows.map((message) => ({ ...message.raw, ...message }))
|
||||
console.log(
|
||||
`[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}`
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { nativeImage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import QRCode from 'qrcode'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
PublishWechatShareCardResult
|
||||
} from '../shared/wechat-share-card'
|
||||
import { WechatShareConfigStore } from './wechat-share-config-store'
|
||||
|
||||
const MAX_REPORT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
export class WechatShareCardService {
|
||||
constructor(private readonly configStore: WechatShareConfigStore) {}
|
||||
|
||||
async publish(request: PublishWechatShareCardRequest): Promise<PublishWechatShareCardResult> {
|
||||
try {
|
||||
const config = await this.configStore.loadRaw()
|
||||
if (!config) return { success: false, error: '请先配置微信卡片服务' }
|
||||
const png = await fs.readFile(request.pngPath)
|
||||
if (!png.length) return { success: false, error: '日报图片为空' }
|
||||
if (png.length > MAX_REPORT_BYTES) return { success: false, error: '日报图片不能超过 25 MB' }
|
||||
|
||||
const source = nativeImage.createFromBuffer(png)
|
||||
if (source.isEmpty()) return { success: false, error: '无法读取日报图片' }
|
||||
const size = source.getSize()
|
||||
const squareSize = Math.min(size.width, size.height)
|
||||
const thumbnail = source
|
||||
.crop({ x: 0, y: 0, width: squareSize, height: squareSize })
|
||||
.resize({ width: 360, height: 360, quality: 'best' })
|
||||
.toJPEG(84)
|
||||
|
||||
const response = await fetch(`${config.serviceUrl}/api/cards`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.uploadToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: request.title.trim().slice(0, 64),
|
||||
description: request.description.trim().slice(0, 120),
|
||||
expiresInDays: Math.max(1, Math.min(30, request.expiresInDays || 7)),
|
||||
imageBase64: png.toString('base64'),
|
||||
thumbnailBase64: thumbnail.toString('base64')
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000)
|
||||
})
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
cardId?: string
|
||||
shareUrl?: string
|
||||
viewUrl?: string
|
||||
expiresAt?: string
|
||||
error?: string
|
||||
}
|
||||
if (!response.ok || !payload.shareUrl) {
|
||||
return { success: false, error: payload.error || `卡片服务返回 HTTP ${response.status}` }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
cardId: payload.cardId,
|
||||
shareUrl: payload.shareUrl,
|
||||
viewUrl: payload.viewUrl,
|
||||
expiresAt: payload.expiresAt,
|
||||
qrCodeDataUrl: await QRCode.toDataURL(payload.shareUrl, {
|
||||
width: 360,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
WechatShareServiceConfig,
|
||||
WechatShareServiceConfigResult
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
const normalizeUrl = (value: string): string => value.trim().replace(/\/+$/, '')
|
||||
|
||||
const validate = (config: WechatShareServiceConfig): string | null => {
|
||||
try {
|
||||
const url = new URL(normalizeUrl(config.serviceUrl))
|
||||
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
return '卡片服务必须使用 HTTPS'
|
||||
}
|
||||
} catch {
|
||||
return '卡片服务地址无效'
|
||||
}
|
||||
if (config.uploadToken.trim().length < 24) return '上传密钥至少需要 24 个字符'
|
||||
return null
|
||||
}
|
||||
|
||||
export class WechatShareConfigStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-share-service.bin')
|
||||
}
|
||||
|
||||
async loadRaw(): Promise<WechatShareServiceConfig | null> {
|
||||
if (!(await fs.pathExists(this.filePath))) return null
|
||||
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const parsed = JSON.parse(safeStorage.decryptString(encrypted)) as WechatShareServiceConfig
|
||||
const error = validate(parsed)
|
||||
if (error) throw new Error(error)
|
||||
return { serviceUrl: normalizeUrl(parsed.serviceUrl), uploadToken: parsed.uploadToken.trim() }
|
||||
}
|
||||
|
||||
async status(): Promise<WechatShareServiceConfigResult> {
|
||||
try {
|
||||
const config = await this.loadRaw()
|
||||
return {
|
||||
success: true,
|
||||
configured: Boolean(config),
|
||||
serviceUrl: config?.serviceUrl
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async save(config: WechatShareServiceConfig): Promise<WechatShareServiceConfigResult> {
|
||||
const normalized = {
|
||||
serviceUrl: normalizeUrl(config.serviceUrl),
|
||||
uploadToken: config.uploadToken.trim()
|
||||
}
|
||||
const error = validate(normalized)
|
||||
if (error) return { success: false, error }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(JSON.stringify(normalized)), {
|
||||
mode: 0o600
|
||||
})
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
return { success: true, configured: true, serviceUrl: normalized.serviceUrl }
|
||||
}
|
||||
}
|
||||
Vendored
+42
-7
@@ -1,15 +1,23 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { Contact, Message } from '../shared/types'
|
||||
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test'
|
||||
import {
|
||||
DeleteGeneratedReportResult,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
SaveGeneratedReportResult,
|
||||
PrepareGeneratedReportTemplateSwitchResult,
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../shared/report-history'
|
||||
import type {
|
||||
DatabaseKeyEnvironment,
|
||||
DatabaseInitResult,
|
||||
DatabaseKeyStorageResult,
|
||||
DatabaseKeyValidationResult,
|
||||
AccountDiscoveryResult
|
||||
@@ -32,6 +40,7 @@ import type {
|
||||
AIProviderConfig,
|
||||
AIProviderListResult,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionRuntimeConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
LegacyAIConfig
|
||||
@@ -57,7 +66,8 @@ import type {
|
||||
VoiceModelDownloadResult,
|
||||
VoiceModelProgressEvent,
|
||||
VoiceModelStatus,
|
||||
VoiceRecognitionResult
|
||||
VoiceRecognitionResult,
|
||||
VoiceTranscriptSnapshot
|
||||
} from '../shared/voice-recognition'
|
||||
import type {
|
||||
AiSearchCancelResult,
|
||||
@@ -70,6 +80,12 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
PublishWechatShareCardResult,
|
||||
WechatShareServiceConfig,
|
||||
WechatShareServiceConfigResult
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
@@ -141,10 +157,8 @@ declare global {
|
||||
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
||||
getCacheSummary: () => Promise<CacheSummary>
|
||||
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
|
||||
initDb: (
|
||||
key: string,
|
||||
accountRoot: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
openKnowledgeDirectory: () => Promise<{ success: boolean; error?: string }>
|
||||
initDb: (key: string, accountRoot: string) => Promise<boolean | DatabaseInitResult>
|
||||
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
|
||||
getBootstrapCache: () => Promise<{
|
||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
@@ -225,6 +239,7 @@ declare global {
|
||||
}>
|
||||
listAIProviders: () => Promise<AIProviderListResult>
|
||||
getAIRuntimeConfig: () => Promise<AIRuntimeModelConfig>
|
||||
getAIVisionRuntimeConfig: () => Promise<AIVisionRuntimeConfig>
|
||||
getAiSearchProviderStatus: () => Promise<AiSearchProviderStatus>
|
||||
authorizeAiSearchExternalProvider: (
|
||||
request: AiSearchExternalAuthorizationRequest
|
||||
@@ -248,6 +263,9 @@ declare global {
|
||||
removeVoiceModel: () => Promise<VoiceModelStatus>
|
||||
openVoiceModelDirectory: () => Promise<{ success: boolean; error?: string }>
|
||||
recognizeVoice: (reference: VoiceMessageReference) => Promise<VoiceRecognitionResult>
|
||||
getVoiceTranscriptSnapshot: (
|
||||
reference: VoiceMessageReference
|
||||
) => Promise<VoiceTranscriptSnapshot>
|
||||
cancelVoiceRecognition: (reference: VoiceMessageReference) => Promise<{ success: boolean }>
|
||||
getVoiceBatchPreflight: (request: VoiceBatchRequest) => Promise<VoiceBatchPreflight>
|
||||
getVoiceBatchConversationSummaries: (
|
||||
@@ -298,16 +316,33 @@ declare global {
|
||||
httpStatus?: number
|
||||
}>
|
||||
startExport: (request: ExportRequest) => Promise<ExportResult>
|
||||
selectExportDirectory: () => Promise<{ canceled: boolean; path?: string }>
|
||||
cancelExport: (jobId: string) => Promise<{ success: boolean }>
|
||||
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
exportGroupReportSnapshot: (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
) => Promise<GroupReportExportResult>
|
||||
prepareGeneratedReportTemplateSwitch: (
|
||||
reportId: string
|
||||
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
|
||||
listGeneratedReports: () => Promise<ReportHistoryResult>
|
||||
saveGeneratedReport: (
|
||||
request: SaveGeneratedReportRequest
|
||||
) => Promise<SaveGeneratedReportResult>
|
||||
updateGeneratedReportTemplate: (
|
||||
request: UpdateGeneratedReportTemplateRequest
|
||||
) => Promise<UpdateGeneratedReportTemplateResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getWechatShareConfig: () => Promise<WechatShareServiceConfigResult>
|
||||
saveWechatShareConfig: (
|
||||
config: WechatShareServiceConfig
|
||||
) => Promise<WechatShareServiceConfigResult>
|
||||
publishWechatShareCard: (
|
||||
request: PublishWechatShareCardRequest
|
||||
) => Promise<PublishWechatShareCardResult>
|
||||
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
readDatabaseKeyClipboard: () => Promise<{
|
||||
|
||||
+33
-3
@@ -1,7 +1,13 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||
import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type {
|
||||
SaveGeneratedReportRequest,
|
||||
UpdateGeneratedReportTemplateRequest
|
||||
} from '../shared/report-history'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AiSearchExternalAuthorizationRequest,
|
||||
@@ -34,7 +40,8 @@ import type {
|
||||
VoiceModelDownloadResult,
|
||||
VoiceModelProgressEvent,
|
||||
VoiceModelStatus,
|
||||
VoiceRecognitionResult
|
||||
VoiceRecognitionResult,
|
||||
VoiceTranscriptSnapshot
|
||||
} from '../shared/voice-recognition'
|
||||
import type {
|
||||
AiSearchCancelResult,
|
||||
@@ -47,6 +54,10 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
WechatShareServiceConfig
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -66,6 +77,8 @@ const api = {
|
||||
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
||||
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<CacheSummary> =>
|
||||
ipcRenderer.invoke('cache:clear', scope),
|
||||
openKnowledgeDirectory: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('cache:openKnowledgeDirectory'),
|
||||
initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
|
||||
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
||||
ipcRenderer.invoke('accounts:discover', inputPath),
|
||||
@@ -111,6 +124,7 @@ const api = {
|
||||
ipcRenderer.invoke('ai:chat', messages, options),
|
||||
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
|
||||
getAIRuntimeConfig: () => ipcRenderer.invoke('ai:getRuntimeConfig'),
|
||||
getAIVisionRuntimeConfig: () => ipcRenderer.invoke('ai:getVisionRuntimeConfig'),
|
||||
getAiSearchProviderStatus: (): Promise<AiSearchProviderStatus> =>
|
||||
ipcRenderer.invoke('ai-search:getProviderStatus'),
|
||||
authorizeAiSearchExternalProvider: (
|
||||
@@ -137,6 +151,10 @@ const api = {
|
||||
ipcRenderer.invoke('voice:openModelDirectory'),
|
||||
recognizeVoice: (reference: VoiceMessageReference): Promise<VoiceRecognitionResult> =>
|
||||
ipcRenderer.invoke('voice:recognize', reference),
|
||||
getVoiceTranscriptSnapshot: (
|
||||
reference: VoiceMessageReference
|
||||
): Promise<VoiceTranscriptSnapshot> =>
|
||||
ipcRenderer.invoke('voice:getTranscriptSnapshot', reference),
|
||||
cancelVoiceRecognition: (reference: VoiceMessageReference): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('voice:cancelRecognition', reference),
|
||||
getVoiceBatchPreflight: (request: VoiceBatchRequest): Promise<VoiceBatchPreflight> =>
|
||||
@@ -191,6 +209,7 @@ const api = {
|
||||
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
|
||||
revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
|
||||
selectExportDirectory: () => ipcRenderer.invoke('export:selectDirectory'),
|
||||
onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
|
||||
callback(progress)
|
||||
@@ -199,12 +218,23 @@ const api = {
|
||||
},
|
||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
exportGroupReportSnapshot: (request: GroupReportRenderSnapshotExportRequest) =>
|
||||
ipcRenderer.invoke('report:exportSnapshot', request),
|
||||
prepareGeneratedReportTemplateSwitch: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:prepareTemplateSwitch', { reportId }),
|
||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
|
||||
ipcRenderer.invoke('report:saveGenerated', request),
|
||||
updateGeneratedReportTemplate: (request: UpdateGeneratedReportTemplateRequest) =>
|
||||
ipcRenderer.invoke('report:updateGeneratedTemplate', request),
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
getWechatShareConfig: () => ipcRenderer.invoke('wechat-share:getConfig'),
|
||||
saveWechatShareConfig: (config: WechatShareServiceConfig) =>
|
||||
ipcRenderer.invoke('wechat-share:saveConfig', config),
|
||||
publishWechatShareCard: (request: PublishWechatShareCardRequest) =>
|
||||
ipcRenderer.invoke('wechat-share:publish', request),
|
||||
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
|
||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>WechatExplorer</title>
|
||||
<title>TraceMemo(迹忆)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/brand-icon.svg" />
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
|
||||
+192
-19
@@ -6,7 +6,11 @@ import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
||||
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
||||
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
|
||||
import type { SettingsCategoryId } from './features/settings/model/types'
|
||||
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
|
||||
import type {
|
||||
AIProviderSummary,
|
||||
AIRuntimeModelConfig,
|
||||
ReportModelChoice
|
||||
} from '../../shared/ai-provider'
|
||||
import { AppPage } from './components/layout/navigation'
|
||||
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
||||
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
||||
@@ -31,6 +35,8 @@ import {
|
||||
sortMessagesChronologically
|
||||
} from './utils/message-pages'
|
||||
import { enrichQuotedMessages } from './utils/quoted-messages'
|
||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -71,6 +77,51 @@ const INITIAL_MESSAGE_COUNT = 20
|
||||
const MESSAGE_PAGE_SIZE = 100
|
||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||
const EXPORT_PREVIEW_LIMIT = 20
|
||||
const REPORT_TEXT_MODEL_STORAGE_KEY = 'group_report_text_model'
|
||||
const REPORT_VISION_MODEL_STORAGE_KEY = 'group_report_vision_model'
|
||||
|
||||
const reportModelKey = (model: { providerId?: string; model: string }): string =>
|
||||
model.providerId && model.model ? `${model.providerId}::${model.model}` : ''
|
||||
|
||||
const reportProviderConfigured = (provider: AIProviderSummary): boolean =>
|
||||
Boolean(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
|
||||
|
||||
const reportModelChoices = (
|
||||
providers: AIProviderSummary[],
|
||||
capability: 'chat' | 'vision'
|
||||
): ReportModelChoice[] =>
|
||||
providers.flatMap((provider) => {
|
||||
if (!reportProviderConfigured(provider)) return []
|
||||
return provider.models
|
||||
.filter((model) =>
|
||||
capability === 'chat'
|
||||
? model.capabilities.chat
|
||||
: model.capabilities.vision || model.capabilities.ocr
|
||||
)
|
||||
.map((model) => ({
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model: model.id,
|
||||
modelName: model.name || model.id,
|
||||
configured: true as const,
|
||||
status: provider.status,
|
||||
timeoutMs: provider.advanced.timeoutMs
|
||||
}))
|
||||
})
|
||||
|
||||
const selectReportModel = (
|
||||
choices: ReportModelChoice[],
|
||||
storageKey: string,
|
||||
fallback: { providerId?: string; model: string }
|
||||
): ReportModelChoice | undefined => {
|
||||
const savedKey = localStorage.getItem(storageKey) || ''
|
||||
const fallbackKey = reportModelKey(fallback)
|
||||
return (
|
||||
choices.find((choice) => reportModelKey(choice) === savedKey) ||
|
||||
choices.find((choice) => reportModelKey(choice) === fallbackKey) ||
|
||||
choices[0]
|
||||
)
|
||||
}
|
||||
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
@@ -207,6 +258,16 @@ function App(): React.ReactElement {
|
||||
configured: false,
|
||||
status: 'untested'
|
||||
})
|
||||
const [reportTextModelConfig, setReportTextModelConfig] = useState<AiModelConfig>({
|
||||
providerName: '尚未配置',
|
||||
model: '',
|
||||
modelName: '尚未选择模型',
|
||||
configured: false,
|
||||
status: 'untested'
|
||||
})
|
||||
const [aiVisionModelConfig, setAiVisionModelConfig] = useState<ReportModelChoice>()
|
||||
const [reportTextModelOptions, setReportTextModelOptions] = useState<ReportModelChoice[]>([])
|
||||
const [reportVisionModelOptions, setReportVisionModelOptions] = useState<ReportModelChoice[]>([])
|
||||
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||
@@ -334,7 +395,25 @@ function App(): React.ReactElement {
|
||||
localStorage.removeItem('ai_model')
|
||||
}
|
||||
}
|
||||
setAiModelConfig(await window.api.getAIRuntimeConfig())
|
||||
const [runtime, visionRuntime, providerList] = await Promise.all([
|
||||
window.api.getAIRuntimeConfig(),
|
||||
window.api.getAIVisionRuntimeConfig(),
|
||||
window.api.listAIProviders()
|
||||
])
|
||||
const providers = providerList.success ? providerList.providers : []
|
||||
const textChoices = reportModelChoices(providers, 'chat')
|
||||
const visionChoices = reportModelChoices(providers, 'vision')
|
||||
const selectedText = selectReportModel(textChoices, REPORT_TEXT_MODEL_STORAGE_KEY, runtime)
|
||||
const selectedVision = selectReportModel(
|
||||
visionChoices,
|
||||
REPORT_VISION_MODEL_STORAGE_KEY,
|
||||
visionRuntime
|
||||
)
|
||||
setReportTextModelOptions(textChoices)
|
||||
setReportVisionModelOptions(visionChoices)
|
||||
setAiModelConfig(runtime)
|
||||
setReportTextModelConfig(selectedText || runtime)
|
||||
setAiVisionModelConfig(selectedVision)
|
||||
} catch (error) {
|
||||
console.warn('[AI Provider] 配置加载失败:', error)
|
||||
}
|
||||
@@ -347,7 +426,8 @@ function App(): React.ReactElement {
|
||||
sourceContact: reportSourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig: aiModelConfig
|
||||
modelConfig: reportTextModelConfig,
|
||||
visionModelConfig: aiVisionModelConfig
|
||||
})
|
||||
const lastCapturedReportKeyRef = React.useRef('')
|
||||
|
||||
@@ -378,12 +458,19 @@ function App(): React.ReactElement {
|
||||
const handleStartExport = async (
|
||||
request: ExportRequest
|
||||
): Promise<import('../../shared/export').ExportResult> => {
|
||||
const targetNames = request.targets.map((target) => target.name)
|
||||
const targetLabel =
|
||||
targetNames.length > 1 ? `${targetNames[0]} 等 ${targetNames.length} 个聊天` : targetNames[0]
|
||||
const exportAll = request.scope === 'all'
|
||||
const targetCount = request.targets.length
|
||||
const targetNames = exportAll ? [] : request.targets.map((target) => target.name)
|
||||
const targetLabel = exportAll
|
||||
? `全部 ${targetCount} 个聊天`
|
||||
: targetNames.length > 1
|
||||
? `${targetNames[0]} 等 ${targetNames.length} 个聊天`
|
||||
: targetNames[0]
|
||||
const task: ExportTaskRecord = {
|
||||
jobId: request.jobId,
|
||||
targetIds: request.targets.map((target) => target.userMd5),
|
||||
scope: request.scope,
|
||||
allContactTypes: request.allContactTypes,
|
||||
targetIds: exportAll ? [] : request.targets.map((target) => target.userMd5),
|
||||
targetNames,
|
||||
targetLabel,
|
||||
format: request.format,
|
||||
@@ -798,7 +885,7 @@ function App(): React.ReactElement {
|
||||
})
|
||||
setStartupProgress({
|
||||
title: '正在加载账号信息...',
|
||||
subtitle: '即将进入 WechatExplorer',
|
||||
subtitle: '即将进入 TraceMemo',
|
||||
detail: '正在读取联系人和当前账号',
|
||||
percent: 70
|
||||
})
|
||||
@@ -1455,7 +1542,9 @@ function App(): React.ReactElement {
|
||||
reportGeneration.phase !== 'success' ||
|
||||
!reportSourceContact ||
|
||||
!reportGeneration.generatedImage ||
|
||||
!reportGeneration.reportPaths
|
||||
!reportGeneration.reportPaths ||
|
||||
!reportGeneration.reportSnapshot ||
|
||||
!reportGeneration.reportMetadata
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -1465,6 +1554,8 @@ function App(): React.ReactElement {
|
||||
lastCapturedReportKeyRef.current = recordKey
|
||||
setLatestGeneratedReportId(null)
|
||||
setIsSavingGeneratedReport(true)
|
||||
const reportSnapshot = reportGeneration.reportSnapshot
|
||||
const reportMetadata = reportGeneration.reportMetadata
|
||||
|
||||
const saveReport = async (): Promise<void> => {
|
||||
const result = await window.api.saveGeneratedReport({
|
||||
@@ -1483,9 +1574,15 @@ function App(): React.ReactElement {
|
||||
htmlPath: reportGeneration.reportPaths?.htmlPath,
|
||||
pngPath: reportGeneration.reportPaths?.pngPath,
|
||||
duration: reportGeneration.generationMetadata.durationMs,
|
||||
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
|
||||
textModelName:
|
||||
reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
||||
imageModelName: aiVisionModelConfig?.modelName || aiVisionModelConfig?.model,
|
||||
modelName: reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
||||
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
|
||||
generationLogs: reportGeneration.generationMetadata.generationLogs
|
||||
generationLogs: reportGeneration.generationMetadata.generationLogs,
|
||||
reportSnapshot,
|
||||
reportMetadata,
|
||||
templateId: reportGeneration.templateId
|
||||
})
|
||||
|
||||
if (!result.success || !result.record) {
|
||||
@@ -1505,12 +1602,18 @@ function App(): React.ReactElement {
|
||||
|
||||
void saveReport()
|
||||
}, [
|
||||
aiModelConfig.model,
|
||||
aiVisionModelConfig?.model,
|
||||
aiVisionModelConfig?.modelName,
|
||||
reportTextModelConfig.model,
|
||||
reportTextModelConfig.modelName,
|
||||
reportGeneration.generatedImage,
|
||||
reportGeneration.generationMetadata,
|
||||
reportGeneration.phase,
|
||||
reportGeneration.reportMessages.length,
|
||||
reportGeneration.reportMetadata,
|
||||
reportGeneration.reportPaths,
|
||||
reportGeneration.reportSnapshot,
|
||||
reportGeneration.templateId,
|
||||
reportSourceContact,
|
||||
summaryDateRange
|
||||
])
|
||||
@@ -1557,6 +1660,30 @@ function App(): React.ReactElement {
|
||||
return window.api.revealGroupReport(filePath)
|
||||
}
|
||||
|
||||
const handleSwitchReportTemplate = async (
|
||||
report: GeneratedReportRecord,
|
||||
templateId: SelectableReportTemplateId
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const updated = await withTimeout(
|
||||
switchGeneratedReportTemplate(report, templateId, window.api),
|
||||
120_000,
|
||||
'模板切换超时,请稍后重试'
|
||||
)
|
||||
if (!updated.success || !updated.record) {
|
||||
return { success: false, error: updated.error || '日报模板更新失败' }
|
||||
}
|
||||
|
||||
setGeneratedReports((current) =>
|
||||
current.map((item) => (item.id === report.id ? updated.record! : item))
|
||||
)
|
||||
reportGeneration.setTemplateId(templateId)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReport = async (
|
||||
reportId: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -1627,6 +1754,7 @@ function App(): React.ReactElement {
|
||||
onRegenerate={handleRegenerateReport}
|
||||
onCopyImage={handleCopyReportImage}
|
||||
onReveal={handleRevealReport}
|
||||
onSwitchTemplate={handleSwitchReportTemplate}
|
||||
/>
|
||||
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
||||
</div>
|
||||
@@ -1645,7 +1773,10 @@ function App(): React.ReactElement {
|
||||
sourceContact={reportSourceContact}
|
||||
summaryDateRange={summaryDateRange}
|
||||
summaryMessageTypes={summaryMessageTypes}
|
||||
modelConfig={aiModelConfig}
|
||||
modelConfig={reportTextModelConfig}
|
||||
visionModelConfig={aiVisionModelConfig}
|
||||
textModelOptions={reportTextModelOptions}
|
||||
visionModelOptions={reportVisionModelOptions}
|
||||
rangeMessageCount={reportGeneration.rangeMessages.length}
|
||||
reportMessageCount={reportGeneration.reportMessages.length}
|
||||
messageTypeCounts={reportGeneration.messageTypeCounts}
|
||||
@@ -1658,6 +1789,14 @@ function App(): React.ReactElement {
|
||||
onSummaryDateRangeChange={setSummaryDateRange}
|
||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
||||
onOpenModelSettings={openModelSettings}
|
||||
onTextModelChange={(model) => {
|
||||
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
|
||||
setReportTextModelConfig(model)
|
||||
}}
|
||||
onVisionModelChange={(model) => {
|
||||
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
|
||||
setAiVisionModelConfig(model)
|
||||
}}
|
||||
onGenerate={() => {
|
||||
reportGeneration.resetGenerationStatus()
|
||||
void reportGeneration.generate()
|
||||
@@ -1677,10 +1816,15 @@ function App(): React.ReactElement {
|
||||
<ReportTaskStatusPanel
|
||||
phase={reportGeneration.phase}
|
||||
error={reportGeneration.error}
|
||||
onRetry={() => {
|
||||
reportGeneration.resetGenerationStatus()
|
||||
void reportGeneration.retry()
|
||||
}}
|
||||
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
|
||||
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
|
||||
preparationProgress={reportGeneration.preparationProgress}
|
||||
imageInsightSummary={reportGeneration.imageInsightSummary}
|
||||
canRetryModelStep={reportGeneration.canRetryModelStep}
|
||||
currentModel={reportTextModelConfig}
|
||||
onRetry={(model) => void reportGeneration.retry(model)}
|
||||
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
||||
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1716,7 +1860,36 @@ function App(): React.ReactElement {
|
||||
onContactsChange={setContacts}
|
||||
onFilteredContactsChange={setFilteredContacts}
|
||||
onReturnToLogin={handleReturnToLogin}
|
||||
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
|
||||
onAIRuntimeChange={(config: AIRuntimeModelConfig) => {
|
||||
setAiModelConfig(config)
|
||||
void Promise.all([
|
||||
window.api.getAIVisionRuntimeConfig(),
|
||||
window.api.listAIProviders()
|
||||
])
|
||||
.then(([visionRuntime, providerList]) => {
|
||||
const providers = providerList.success ? providerList.providers : []
|
||||
const textChoices = reportModelChoices(providers, 'chat')
|
||||
const visionChoices = reportModelChoices(providers, 'vision')
|
||||
setReportTextModelOptions(textChoices)
|
||||
setReportVisionModelOptions(visionChoices)
|
||||
setReportTextModelConfig(
|
||||
(current) =>
|
||||
selectReportModel(
|
||||
textChoices,
|
||||
REPORT_TEXT_MODEL_STORAGE_KEY,
|
||||
current.configured ? current : config
|
||||
) || config
|
||||
)
|
||||
setAiVisionModelConfig((current) =>
|
||||
selectReportModel(
|
||||
visionChoices,
|
||||
REPORT_VISION_MODEL_STORAGE_KEY,
|
||||
current || visionRuntime
|
||||
)
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}}
|
||||
onNotice={setReportNotice}
|
||||
onOpenSettings={openSettings}
|
||||
onAppearanceChange={handleAppearanceChange}
|
||||
@@ -1804,7 +1977,7 @@ function App(): React.ReactElement {
|
||||
? autoConnectSource === 'env'
|
||||
? '检测到环境变量中的密钥'
|
||||
: '使用上次安全保存的密钥'
|
||||
: 'WechatExplorer')
|
||||
: 'TraceMemo')
|
||||
return (
|
||||
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
|
||||
<div className="boot-splash-spinner" aria-hidden />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
|
||||
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
@@ -140,12 +141,12 @@ export function DatabaseConnectionPage({
|
||||
|
||||
return (
|
||||
<main className="database-login-page">
|
||||
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
|
||||
<section className="database-login-brand" aria-label="TraceMemo(迹忆)产品说明">
|
||||
<div className="database-login-brand-content">
|
||||
<div className="database-login-logo" aria-hidden="true">
|
||||
<LineIcon name="database" />
|
||||
</div>
|
||||
<h1>WechatExplorer</h1>
|
||||
<h1>TraceMemo(迹忆)</h1>
|
||||
<p className="database-login-tagline">让 AI 读懂你的微信</p>
|
||||
<p className="database-login-description">
|
||||
连接成功后,你可以搜索聊天记录、生成群聊日报,并按需使用 AI 分析。
|
||||
@@ -248,7 +249,7 @@ export function DatabaseConnectionPage({
|
||||
'让微信停在登录页面',
|
||||
'确认开始准备',
|
||||
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
|
||||
'现在可以登录微信',
|
||||
'获取成功, 现在可以重新登录微信了',
|
||||
'验证数据库连接'
|
||||
][guideStep - 1]}
|
||||
</strong>
|
||||
@@ -260,7 +261,7 @@ export function DatabaseConnectionPage({
|
||||
'确认下方检测结果;没有找到目录时可以手动选择。',
|
||||
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
|
||||
'开始后请按页面提示完成系统授权。',
|
||||
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
|
||||
'正在准备连接组件,请不要关闭微信或 TraceMemo。',
|
||||
'请回到微信完成登录,登录成功后再回来验证。',
|
||||
'正在验证密钥和本地数据库,请稍候。'
|
||||
][guideStep - 1]}
|
||||
@@ -425,7 +426,7 @@ export function DatabaseConnectionPage({
|
||||
onClick={onValidateConnection}
|
||||
disabled={!dbKey || isConnecting}
|
||||
>
|
||||
{isConnecting ? '正在验证…' : '微信已登录,验证连接'}
|
||||
{isConnecting ? '正在验证…' : '验证连接'}
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 6 && (
|
||||
@@ -461,7 +462,12 @@ export function DatabaseConnectionPage({
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
'Windows 已完整支持,不需要关闭 SIP。'
|
||||
<>
|
||||
Windows 需要 Microsoft Visual C++ 2015-2022 x64 运行库。{' '}
|
||||
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
下载运行库
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{showMacKeyFaq && isMac && (
|
||||
@@ -521,6 +527,14 @@ export function DatabaseConnectionPage({
|
||||
</div>
|
||||
)}
|
||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||
{platform === 'win32' && (
|
||||
<p className="database-login-platform-note">
|
||||
无法加载数据库组件时,请安装 Microsoft Visual C++ 2015-2022 x64 运行库。{' '}
|
||||
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
下载运行库
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
@@ -553,7 +567,7 @@ export function DatabaseConnectionPage({
|
||||
<button type="button" onClick={onClearKey}>
|
||||
清除已保存密钥
|
||||
</button>
|
||||
<span>WechatExplorer</span>
|
||||
<span>TraceMemo</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -478,7 +478,7 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
API 默认仅监听本机,并通过 Bearer Token 保护数据接口。Token 请在 API Center
|
||||
中显示或复制。关闭后 Claude / Codex 等客户端无法读取聊天数据。
|
||||
<br />
|
||||
配置文档:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
|
||||
配置文档:<code>docs/skill/tracememo-reader/SKILL.md</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import type { ExportContactType } from '../../../../shared/export'
|
||||
import type { Contact, SelfInfo } from './exportTypes'
|
||||
import { displayName } from './exportUtils'
|
||||
|
||||
@@ -8,6 +9,9 @@ interface ExportContactPanelProps {
|
||||
activeContact: Contact | null
|
||||
selectedContactIds: string[]
|
||||
selectionMode: boolean
|
||||
exportAll: boolean
|
||||
allContactTypes: ExportContactType[]
|
||||
exportRunning: boolean
|
||||
selectionLimit: number
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
@@ -17,6 +21,8 @@ interface ExportContactPanelProps {
|
||||
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onCompleteSelection: () => void
|
||||
onExportAll: () => void
|
||||
onToggleAllContactType: (type: ExportContactType) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
@@ -26,6 +32,9 @@ export function ExportContactPanel({
|
||||
activeContact,
|
||||
selectedContactIds,
|
||||
selectionMode,
|
||||
exportAll,
|
||||
allContactTypes,
|
||||
exportRunning,
|
||||
selectionLimit,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
@@ -35,8 +44,16 @@ export function ExportContactPanel({
|
||||
onContactTypeChange,
|
||||
onSelectContact,
|
||||
onCompleteSelection,
|
||||
onExportAll,
|
||||
onToggleAllContactType,
|
||||
onOpenSettings
|
||||
}: ExportContactPanelProps): React.ReactElement {
|
||||
const groupCount = contacts.filter((contact) => contact.type === 'group').length
|
||||
const userCount = contacts.length - groupCount
|
||||
const selectedAllCount =
|
||||
(allContactTypes.includes('group') ? groupCount : 0) +
|
||||
(allContactTypes.includes('user') ? userCount : 0)
|
||||
|
||||
return (
|
||||
<aside className="export-contact-panel">
|
||||
<div className="export-panel-header">
|
||||
@@ -71,9 +88,55 @@ export function ExportContactPanel({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-all-button ${exportAll ? 'active' : ''}`}
|
||||
aria-pressed={exportAll}
|
||||
onClick={onExportAll}
|
||||
>
|
||||
<span>
|
||||
<strong>全部导出</strong>
|
||||
<small>群聊和联系人按会话归档</small>
|
||||
</span>
|
||||
<b>{(exportAll ? selectedAllCount : contacts.length).toLocaleString()}</b>
|
||||
</button>
|
||||
{exportAll && (
|
||||
<div className="export-all-type-options" aria-label="全部导出范围">
|
||||
{(
|
||||
[
|
||||
['group', '群聊'],
|
||||
['user', '联系人']
|
||||
] as const
|
||||
).map(([type, label]) => {
|
||||
const count = type === 'group' ? groupCount : userCount
|
||||
return (
|
||||
<label key={type}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`导出全部${label}`}
|
||||
checked={allContactTypes.includes(type)}
|
||||
disabled={
|
||||
exportRunning || (allContactTypes.length === 1 && allContactTypes[0] === type)
|
||||
}
|
||||
onChange={() => onToggleAllContactType(type)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
<b>{count.toLocaleString()}</b>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
{exportAll ? (
|
||||
<div className="export-all-status">
|
||||
已选择 {allContactTypes.includes('group') ? `全部群聊 ${groupCount} 个` : ''}
|
||||
{allContactTypes.length === 2 ? '和' : ''}
|
||||
{allContactTypes.includes('user') ? `全部联系人 ${userCount} 个` : ''}
|
||||
;点击单个聊天可切换回指定导出
|
||||
</div>
|
||||
) : selectionMode ? (
|
||||
<div className="export-multi-select-bar">
|
||||
<span>
|
||||
已选 {selectedContactIds.length} / {selectionLimit} 个
|
||||
@@ -82,21 +145,24 @@ export function ExportContactPanel({
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="export-contact-list">
|
||||
{filteredContacts.map((contact) => {
|
||||
const name = displayName(contact)
|
||||
const selected = selectedContactIds.includes(contact.md5)
|
||||
const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit
|
||||
const selectedByAll = exportAll && allContactTypes.includes(contact.type)
|
||||
const visuallySelected = exportAll ? selectedByAll : selected
|
||||
const atLimit =
|
||||
!exportAll && selectionMode && !selected && selectedContactIds.length >= selectionLimit
|
||||
return (
|
||||
<button
|
||||
key={contact.md5}
|
||||
type="button"
|
||||
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||
className={`export-contact-item ${!exportAll && activeContact?.md5 === contact.md5 ? 'active' : ''} ${visuallySelected ? 'selected' : ''}`}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
disabled={atLimit}
|
||||
aria-pressed={selected}
|
||||
aria-pressed={visuallySelected}
|
||||
>
|
||||
<span className="export-contact-avatar">
|
||||
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||
@@ -105,7 +171,7 @@ export function ExportContactPanel({
|
||||
<strong>{name}</strong>
|
||||
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||
</span>
|
||||
{selectionMode && (
|
||||
{!exportAll && selectionMode && (
|
||||
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
|
||||
{selected ? '✓' : ''}
|
||||
</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user