mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-22 05:56:58 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2f8da952 | ||
|
|
91245148ee | ||
|
|
a10f2621fa | ||
|
|
b324f480dd | ||
|
|
262b15db84 | ||
|
|
67b1df7e80 | ||
|
|
740bec6d04 | ||
|
|
65830d70fe | ||
|
|
e9cc996ebb | ||
|
|
a72ae49e2c | ||
|
|
313a78d044 | ||
|
|
8cfdfac056 | ||
|
|
45abfddc5f | ||
|
|
366e2622cc | ||
|
|
3352936744 | ||
|
|
89d388f661 | ||
|
|
f93dc539e4 | ||
|
|
15811c820c | ||
|
|
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 | ||
|
|
c41675b809 | ||
|
|
4cd3ea0bc0 | ||
|
|
32864ff88c | ||
|
|
291c82f0e2 | ||
|
|
666d8896ee | ||
|
|
f2c58f39b0 | ||
|
|
cd2c3cfaee | ||
|
|
a73af3b5ad | ||
|
|
0c21008ec3 | ||
|
|
96c67f5bf8 | ||
|
|
0b845db2e0 | ||
|
|
c125e85ffc | ||
|
|
43654bf0e2 | ||
|
|
e43af6f1fe | ||
|
|
3af62783dd | ||
|
|
88a6d750fb | ||
|
|
0ec2e6a0be | ||
|
|
a0e8ab278f | ||
|
|
ad4b3a8074 | ||
|
|
307d247660 | ||
|
|
e3615c0153 | ||
|
|
3c59fb64e9 | ||
|
|
c4e13ee7d2 | ||
|
|
12cae061df | ||
|
|
17cc99de37 | ||
|
|
933a87ebbb | ||
|
|
55da2e2e67 | ||
|
|
c2f9d352db | ||
|
|
7529a67f09 | ||
|
|
4e84b52cc4 | ||
|
|
66a6ee3e32 | ||
|
|
69bc6f57e7 | ||
|
|
894281fb44 | ||
|
|
2f6ab7b773 | ||
|
|
a0e8be0cdf | ||
|
|
e153ddb794 | ||
|
|
60c501e148 | ||
|
|
c23ed23bd2 | ||
|
|
0a3d930298 | ||
|
|
c6587c517a | ||
|
|
c70e49bf16 |
@@ -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=
|
||||
|
||||
@@ -33,6 +33,10 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
@@ -55,7 +59,7 @@ jobs:
|
||||
run: pnpm test:e2e:build
|
||||
|
||||
- name: Electron E2E tests
|
||||
run: pnpm exec playwright test --grep-invert @visual
|
||||
run: pnpm exec playwright test --grep-invert="@visual"
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
|
||||
@@ -10,8 +10,12 @@ coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
resources/connectors/wechat-personal/
|
||||
.omc
|
||||
.codex/
|
||||
services/share-card-worker/.wrangler/
|
||||
services/share-card-worker/wrangler.local.jsonc
|
||||
skills-lock.json
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
@@ -19,3 +23,5 @@ AGENTS.md
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
.agents
|
||||
*__screenshots__
|
||||
@@ -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
|
||||
|
||||
@@ -2,3 +2,4 @@ singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
endOfLine: auto
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@
|
||||
"cwd": "${workspaceRoot}",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.CMD"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"env": {
|
||||
|
||||
Vendored
+4
-3
@@ -6,6 +6,7 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"files.eol": "\n"
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
# 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">让 AI 读懂你的微信</h2>
|
||||
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
||||
|
||||
<p align="center">
|
||||
本地优先的 AI 微信助手<br />
|
||||
聊天记录查看 · AI 问问微信 · 群聊日报 · Agent · 本地 API
|
||||
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
|
||||
查看聊天 · 找回信息 · AI 问答 · 群聊日报总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -18,431 +18,429 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>📦 下载最新版</b></a>
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md"><b>🚀 第一次使用</b></a>
|
||||
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md#遇到问题"><b>📖 使用说明</b></a>
|
||||
<a href="./docs/README.md"><b>完整文档</b></a>
|
||||
</p>
|
||||
|
||||
> ⭐ 如果这个项目帮助到了你,欢迎点一个 Star,支持项目持续更新。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/software-1.png" alt="WechatExplorer AI 微信助手界面" />
|
||||
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
|
||||
</p>
|
||||
|
||||
> 像问 ChatGPT 一样,直接询问你的微信聊天记录。
|
||||
<p align="center">
|
||||
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" />
|
||||
</p>
|
||||
|
||||
WechatExplorer 是一个基于 Electron + React + TypeScript 开发的本地优先 AI 微信助手。它不只是查看聊天记录,而是把聊天内容变成可以搜索、总结、分析和交给 Agent 使用的信息。
|
||||
---
|
||||
|
||||
**支持:**
|
||||
## TraceMemo(迹忆)是什么
|
||||
|
||||
微信聊天记录查看、AI 微信助手、AI 群聊日报、MCP、Agent、本地 API
|
||||
TraceMemo(迹忆)是一款**本地优先、可追溯的 AI 微信知识与分析工作台**。
|
||||
|
||||
## ✨ 为什么选择 WechatExplorer?
|
||||
TraceMemo 原名 **WechatExplorer**,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
|
||||
|
||||
- ✅ **像 ChatGPT 一样搜索整个微信**:用自然语言提问,快速找到聊天上下文。
|
||||
- ✅ **AI 自动生成群聊日报**:自动整理热点、资源、问答和待跟进事项。
|
||||
- ✅ **Agent 可直接读取微信聊天**:支持 Codex、Claude Code、MCP 等 AI 工作流。
|
||||
- ✅ **本地数据库优先**:聊天数据默认保存在本机,不会自动上传。
|
||||
- ✅ **支持微信 3.x / 4.x**:不同微信版本提供对应版本支持。
|
||||
- ✅ **多格式导出**:支持 HTML、Markdown、CSV 和 JSON。
|
||||
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
|
||||
|
||||
## 🚀 第一次使用
|
||||
你可以直接浏览聊天,也可以用自然语言提问:
|
||||
|
||||
软件已经内置完整的新手引导,通常按下面三步即可开始:
|
||||
|
||||
```text
|
||||
下载软件
|
||||
↓
|
||||
连接微信
|
||||
↓
|
||||
开始问你的微信
|
||||
```
|
||||
|
||||
首次启动会自动进入「第一次使用」页面。连接成功后,软件会显示「开始探索你的微信」;进入主界面后,还可以随时点击左下角「新手引导」重新查看。
|
||||
|
||||
## 📸 功能预览
|
||||
|
||||
### AI 群聊日报
|
||||
|
||||
<details>
|
||||
<summary>点击查看完整日报模板</summary>
|
||||
<br />
|
||||
<img src="./public/report-template-1.png" alt="完整群聊日报模板" />
|
||||
</details>
|
||||
|
||||
### AI 问问微信
|
||||
|
||||
<img src="./public/ai-search.png" alt="AI 问问微信页面" />
|
||||
|
||||
### 本地 API 与 Agent
|
||||
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Agent 页面" />
|
||||
|
||||
## 🎯 它能帮你做什么
|
||||
|
||||
### 🤖 AI 问问微信
|
||||
|
||||
直接向自己的微信提问:
|
||||
|
||||
> “去年我和老板聊过哪些关于涨薪的事情?”
|
||||
> “上个月我们讨论过哪些发布问题?”
|
||||
>
|
||||
> “技术群这周讨论了哪些问题?”
|
||||
> “张三之前发过的项目地址在哪里?”
|
||||
>
|
||||
> “帮我找到张三发过的项目地址。”
|
||||
> “技术交流群今天有哪些结论和待办?”
|
||||
|
||||
### 📰 AI 群聊日报
|
||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。
|
||||
|
||||
选择一个群聊和时间范围,自动生成:
|
||||
你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
||||
|
||||
- ✅ 今日热点
|
||||
- ✅ 一句话总结
|
||||
- ✅ 资源汇总
|
||||
- ✅ 问答整理
|
||||
- ✅ 活跃榜
|
||||
- ✅ 词云与关键词
|
||||
TraceMemo 不提供任何微信聊天数据,也不鼓励收集、上传、出售、共享或未经授权处理他人的聊天记录。使用 TraceMemo 时,请确保你对所处理的数据具有合法的访问和使用权限,并自行承担相应的数据安全与合规责任。
|
||||
|
||||
<details>
|
||||
<summary>展开查看日报的完整模块</summary>
|
||||
---
|
||||
|
||||
## 为什么叫 TraceMemo(迹忆)
|
||||
|
||||
<details>
|
||||
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
|
||||
|
||||
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
|
||||
|
||||
“迹忆”可以理解为“留下痕迹的记忆”。
|
||||
|
||||
TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
|
||||
|
||||
> **品牌说明**
|
||||
>
|
||||
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
|
||||
|
||||
- **今日讨论热点**:梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**:首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**:提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**:标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**:收录群内的精彩对话。
|
||||
- **问题与解答**:整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**:适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**:让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**:消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云 / 关键词**:可视化展示群聊关键词。
|
||||
</details>
|
||||
|
||||
支持导出 HTML 与 PNG,也支持图片理解和图片生成。
|
||||
---
|
||||
|
||||
### 📂 查看聊天
|
||||
|
||||
浏览微信好友和群聊的聊天记录,支持查看:
|
||||
|
||||
- 文本
|
||||
- 图片
|
||||
- 视频
|
||||
- 语音
|
||||
- 文件
|
||||
|
||||
同时支持头像显示、全局搜索、指定会话搜索、消息防撤回和上下文定位。
|
||||
|
||||
### 📤 导出聊天
|
||||
|
||||
支持按会话和时间范围导出聊天记录为 HTML、CSV、JSON 或 Markdown,并可以打开文件所在文件夹。
|
||||
|
||||
### 🤖 Agent
|
||||
|
||||
通过本地 HTTP API 和内置 Reader Skill,让 Codex、Claude Code 等 Agent 在本机服务运行并获得授权后读取、总结聊天数据。
|
||||
|
||||
## 🚀 规划与未来(Roadmap)
|
||||
|
||||
WechatExplorer 仍在持续演进,未来会围绕 **AI 大模型 + 微信 + Agent** 持续完善能力。
|
||||
|
||||
下面是正在设计或计划中的部分功能(不代表发布时间)。
|
||||
## 项目缘起
|
||||
|
||||
<details>
|
||||
<summary>点击展开未来规划</summary>
|
||||
|
||||
### 🚧 人物镜像(Persona)
|
||||
TraceMemo 最早叫 **WechatExplorer**。
|
||||
|
||||
根据长期聊天记录生成每个人的沟通画像:
|
||||
**2025 年 12 月**,我做出了第一个版本。当时功能很简单:解析微信 3.0 的聊天记录,再用 AI 生成群聊日报。最初只是给自己用,想把散落在微信里的信息重新找出来,也方便看看群里每天聊了什么。
|
||||
|
||||
- 兴趣标签
|
||||
- 常聊话题
|
||||
- 表达风格
|
||||
- 个性化沟通参考
|
||||
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
|
||||
|
||||
### 🚧 AI 长期记忆
|
||||
为了支持微信 4.x,我开始重新研究数据访问。这部分工作得到了 **WeFlow** 很大的帮助。TraceMemo 目前的微信 4.x 数据连接能力,参考并使用了 **WeFlow 历史版本中的相关实现和思路**,包括数据库密钥获取、图片解密等底层能力。
|
||||
|
||||
让 AI 持续理解你的聊天历史,在不同时间跨度内建立上下文,支持长期事项追踪和连续对话。
|
||||
> **没有 WeFlow,就没有今天的 TraceMemo。**
|
||||
|
||||
### 🚧 微信卡片分享
|
||||
WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继续做后面的事情:让聊天记录可以被搜索、理解和总结,也让 AI 给出的答案能够回到原始消息核对。
|
||||
|
||||
将 AI 日报生成可点击的微信卡片消息,而不仅仅是图片,方便在群聊中传播与查看。
|
||||
在此基础上,项目陆续加入了:
|
||||
|
||||
- 本地知识库
|
||||
- 消息来源追溯
|
||||
- 群聊日报
|
||||
- 语音消息也参与知识库等问答
|
||||
- 微信机器人
|
||||
- Local HTTP API
|
||||
- Reader Skill
|
||||
- Agent 接入
|
||||
- 多种聊天记录导出能力
|
||||
|
||||
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
|
||||
|
||||
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
|
||||
|
||||
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💬 交流与反馈
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享示例" width="520" />
|
||||
<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/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 把聊天整理成报告,并保存为 HTML 与 PNG 长图。
|
||||
|
||||
报告包含:
|
||||
|
||||
- 热点
|
||||
- 重要消息
|
||||
- 资源
|
||||
- 问答
|
||||
- 待办
|
||||
- 未解决事项
|
||||
- 活跃统计
|
||||
- 图片精选
|
||||
|
||||
具体内容取决于消息、媒体是否可读以及模型能力。
|
||||
详细说明:[生成群聊日报](./docs/user-guide/report.md)
|
||||
|
||||
</details>
|
||||
|
||||
### AI 帮你找回聊过的内容
|
||||
|
||||
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
|
||||
|
||||
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。
|
||||
|
||||
你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/退群监控.png" alt="退群自动监控示例" width="720" />
|
||||
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
|
||||
</p>
|
||||
|
||||
### 💡 更多 AI 能力
|
||||
详细说明:[使用 AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
||||
|
||||
包括会议纪要、聊天知识库、长期事项追踪、个人成长分析等更多探索。
|
||||
### 直接在微信里问你的历史聊天
|
||||
|
||||
WechatExplorer 希望不仅仅是一个聊天记录查看工具,更希望成为一个能够理解、整理和协助管理微信信息的 AI 工作平台。
|
||||
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。
|
||||
|
||||
如果你有好的想法,欢迎提交 Issue 或 Pull Request,一起把它做得更好。
|
||||
例如,你可以直接给机器人发送:
|
||||
|
||||
- “最近 5 个会话”
|
||||
- “张三最近和我聊了什么”
|
||||
- “总结今天的技术交流群”
|
||||
|
||||
TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
|
||||
|
||||
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。
|
||||
|
||||
当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
|
||||
|
||||
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
|
||||
|
||||
---
|
||||
|
||||
## 其他能力
|
||||
|
||||
### 本地知识库
|
||||
|
||||
<details>
|
||||
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。
|
||||
|
||||
它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
|
||||
|
||||
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
|
||||
|
||||
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 快速开始
|
||||
|
||||
### 下载并安装
|
||||
|
||||
当前 WechatExplorer / 迹忆版本:`v2.1.7`。
|
||||
|
||||
应用安装包:[WechatExplorer GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。
|
||||
|
||||
| 系统 | 已测试的微信客户端 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) |
|
||||
|
||||
微信客户端来自上表对应的第三方版本存档,请自行核对来源与文件完整性。
|
||||
|
||||
正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录;但应用缓存和本地设置可能随版本升级变化。升级前仍建议使用微信官方迁移或备份功能备份重要记录,不要将唯一副本保存在单一设备。
|
||||
|
||||
### 连接微信
|
||||
|
||||
按照软件内置的「第一次使用」引导完成连接:
|
||||
|
||||
1. 确认微信数据目录。
|
||||
2. 让微信停在登录页面。
|
||||
3. 点击“开始获取”,按提示完成连接。
|
||||
|
||||
Windows 已完整支持,不需要关闭 SIP。macOS 首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
|
||||
|
||||
### 配置 AI
|
||||
|
||||
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,保存并测试成功后即可使用「问问微信」和「日报」。支持:
|
||||
|
||||
- OpenAI
|
||||
- DeepSeek
|
||||
- Claude
|
||||
- Moonshot
|
||||
- OpenAI 兼容接口
|
||||
|
||||
### 下一步
|
||||
|
||||
| 你想做什么 | 从哪里开始 |
|
||||
| ----------------- | ---------------------------------------------------------------- |
|
||||
| 重新查看连接步骤 | 点击左下角「新手引导」 |
|
||||
| 直接向微信提问 | 打开「问问微信」 |
|
||||
| 生成群聊日报 | 打开「日报」 |
|
||||
| 浏览聊天记录 | 打开「档案」 |
|
||||
| 导出聊天记录 | 打开「导出」 |
|
||||
| 让 Agent 读取微信 | [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md) |
|
||||
|
||||
## 🖥️ 支持平台与微信版本
|
||||
|
||||
- **Windows**:已完整支持 Windows x64,不需要关闭 SIP。
|
||||
- **macOS**:支持 Intel 和 Apple Silicon;首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
|
||||
- **微信 3.0**:请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。
|
||||
- **微信 4.0**:使用当前 Releases 中的最新版。
|
||||
|
||||
不同微信版本、账号和数据目录可能存在差异,遇到连接问题时请优先参考 [使用说明](./docs/user-guide/getting-started.md)。
|
||||
|
||||
## 🔒 隐私与权限
|
||||
|
||||
- WechatExplorer 只读取你有权访问的本机微信数据。
|
||||
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
|
||||
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
|
||||
- 本地 API 默认监听 `127.0.0.1`,无鉴权;请按可信网络范围配置。
|
||||
- 消息防撤回、图片解密和数据库密钥等能力都应只用于你有权访问的数据。
|
||||
|
||||
## 🔌 高级能力:本地 HTTP API 与 Agent
|
||||
### 实验性:生成微信分享卡片
|
||||
|
||||
<details>
|
||||
<summary>展开本地 HTTP API、Reader Skill 和 Agent 说明</summary>
|
||||
TraceMemo 可以把群聊日报长图上传到你自己部署的 Cloudflare Worker 和 R2,并生成可在微信中分享的临时网页、二维码及卡片信息。
|
||||
|
||||
WechatExplorer 内置一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`,纯本地、无鉴权。完成数据库连接后,API 会自动启用。
|
||||
该功能需要自备 Cloudflare 账号、域名和微信测试号,目前不属于开箱即用的稳定功能。
|
||||
|
||||
### 启用本地 API
|
||||
<p align="center">
|
||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享效果示例" />
|
||||
</p>
|
||||
|
||||
1. 安装并启动 WechatExplorer。
|
||||
2. 完成首次密钥配置,解锁 WCDB 数据库。
|
||||
3. 在 `http://127.0.0.1:6131` 使用本地 API。
|
||||
详细说明:[实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)。
|
||||
|
||||
### 7×24 提供 API(菜单栏常驻模式)
|
||||
|
||||
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,可以启用菜单栏模式:
|
||||
|
||||
```bash
|
||||
WXE_TRAY=1 open /Applications/WechatExplorer.app
|
||||
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
|
||||
```
|
||||
|
||||
启用后:
|
||||
|
||||
- macOS Dock 图标自动隐藏。
|
||||
- 菜单栏出现 WechatExplorer 图标,可重新打开主窗口、查看 API 状态。
|
||||
- 主窗口关闭后 API 服务继续运行。
|
||||
|
||||
### API 端点一览
|
||||
|
||||
| 端点 | 说明 |
|
||||
| ------------------------------------------------ | --------------------------------------- |
|
||||
| `GET /api/v1/health` | 健康检查 |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间,用于“今天 / 昨天”换算 |
|
||||
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
|
||||
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
|
||||
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
|
||||
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
|
||||
| `GET /api/v1/resolve?q=群昵称` | 把昵称、wxid 或 md5 解析成 md5 |
|
||||
|
||||
详细参数、返回结构和时间格式见 [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### 安装 Reader Skill,让 Agent 读取和总结群聊
|
||||
|
||||
WechatExplorer 已内置 Reader Skill,无需手动复制仓库中的 `SKILL.md`:
|
||||
|
||||
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
|
||||
2. 打开应用内的「API」页面。
|
||||
3. 在“快速接入”中选择 Codex 或 Claude Code。
|
||||
4. 点击复制安装指令,将指令粘贴给对应 Agent 执行。
|
||||
5. 安装完成后,可以直接向 Agent 提问:
|
||||
|
||||
> “今天技术交流群聊了什么?”
|
||||
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。
|
||||
|
||||
### curl 调试示例(可选)
|
||||
|
||||
不使用 Agent 时,也可以通过 `curl` 直接调试本地 HTTP API:
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 今天“摸鱼交流群”的聊天记录
|
||||
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
|
||||
--data-urlencode "talker=摸鱼交流群" \
|
||||
--data-urlencode "time=$(date +%Y-%m-%d)"
|
||||
|
||||
# 把群昵称解析成 md5
|
||||
curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
--data-urlencode "q=摸鱼交流群"
|
||||
```
|
||||
不熟悉命令行的用户,可以把[自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)直接交给 Codex 或 Claude Code。
|
||||
|
||||
</details>
|
||||
|
||||
## 🛠️ 开发配置(可选)
|
||||
### 转写微信语音
|
||||
|
||||
<details>
|
||||
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。
|
||||
|
||||
转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
|
||||
|
||||
详细说明:[语音转文字](./docs/user-guide/voice.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 导出长期可用的聊天档案
|
||||
|
||||
<details>
|
||||
<summary>展开开发配置、环境变量和构建命令</summary>
|
||||
支持 HTML、CSV、JSON 和 Markdown。
|
||||
|
||||
本地开发需要 Node.js(建议当前 LTS)和 pnpm 7+:
|
||||
HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
|
||||
|
||||
详细说明:[导出聊天](./docs/user-guide/export.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 在外部 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[TraceMemo 读取与解析]
|
||||
B --> C[聊天档案]
|
||||
B --> D[本地知识库与搜索]
|
||||
D --> E[筛选相关聊天来源]
|
||||
E --> F[用户配置的 AI 模型]
|
||||
F --> G[带来源的回答]
|
||||
B --> H[整理日报输入]
|
||||
H --> F
|
||||
B --> I[聊天导出]
|
||||
B --> J[Local HTTP API]
|
||||
J --> K[外部 Agent]
|
||||
L[微信机器人消息] --> M[Agent Hub]
|
||||
M --> B
|
||||
M --> F
|
||||
```
|
||||
|
||||
- 微信数据库读取、聊天解析、知识库索引和离线语音识别在本机完成。
|
||||
- 普通浏览、普通搜索和导出不要求配置 AI 服务。
|
||||
- 使用“问问微信”、群聊日报或图片理解等 AI 功能时,完成任务所需的内容可能发送到你选择的模型服务;具体发送范围和确认方式以对应功能页面为准。
|
||||
- “问问微信”会先在本机缩小范围,不会默认把整个微信数据库作为一次模型请求发送。
|
||||
|
||||
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
|
||||
|
||||
---
|
||||
|
||||
## 支持平台与安装包
|
||||
|
||||
| 平台 | 处理器架构 | Releases 安装包 |
|
||||
| ------- | ------------------------------ | --------------- |
|
||||
| Windows | x64 | `-setup.exe` |
|
||||
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
|
||||
|
||||
当前版本不支持 Intel 芯片的 Mac。
|
||||
|
||||
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
|
||||
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
||||
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
|
||||
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
||||
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
|
||||
|
||||
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
|
||||
|
||||
当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
|
||||
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。
|
||||
|
||||
迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
|
||||
|
||||
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
|
||||
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
|
||||
|
||||
---
|
||||
|
||||
## 配置 AI
|
||||
|
||||
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。
|
||||
|
||||
应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
|
||||
|
||||
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
|
||||
|
||||
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
- [文档首页](./docs/README.md)
|
||||
- [第一次使用](./docs/user-guide/getting-started.md)
|
||||
- [聊天档案与搜索](./docs/user-guide/chat-archive.md)
|
||||
- [AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
||||
- [本地知识库](./docs/user-guide/knowledge.md)
|
||||
- [群聊日报](./docs/user-guide/report.md)
|
||||
- [实验性微信分享卡片](./docs/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)
|
||||
- [数据、隐私与安全](./docs/user-guide/privacy.md)
|
||||
- [Agent 接入](./docs/agent/overview.md)
|
||||
- [微信机器人与 Agent Hub](./docs/agent/agent-hub.md)
|
||||
- [Local HTTP API](./docs/agent/api.md)
|
||||
- [开发与测试](./docs/development/overview.md)
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户不需要配置 `.env`,也可以直接在软件“设置”里填写 AI 和图片解密配置。
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | ----------------------------- | --------------------------- |
|
||||
| `VITE_DB_KEY` | 微信数据库密钥(32 字节 hex) | `YOUR_DB_KEY_HERE` |
|
||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥(hex 格式) | `0x40` |
|
||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥(16 字符) | `YOUR_AES_KEY_HERE` |
|
||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
|
||||
常用命令:
|
||||
常用检查:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # 类型检查
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm build # 构建
|
||||
pnpm build:win # 构建 Windows x64 安装包
|
||||
pnpm typecheck
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
|
||||
</details>
|
||||
完整说明:[开发、测试与构建](./docs/development/overview.md)
|
||||
|
||||
## ❓ FAQ
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>展开常见问题</summary>
|
||||
## 支持与反馈
|
||||
|
||||
### 我已经连接成功,怎么重新查看教程?
|
||||
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
|
||||
|
||||
点击左下角「新手引导」。首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
|
||||
提交 Issue 时请提供:
|
||||
|
||||
### 微信 3.0 应该下载哪个版本?
|
||||
- 操作系统
|
||||
- 微信版本
|
||||
- TraceMemo 版本
|
||||
- 复现步骤
|
||||
- 已遮挡敏感信息的截图
|
||||
|
||||
请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。微信 4.0 用户使用当前 Releases 中的最新版。
|
||||
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
|
||||
|
||||
### AI 问问微信或群聊日报不可用怎么办?
|
||||
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
|
||||
|
||||
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,确认 Base URL 和模型名称正确,然后保存并测试连接。
|
||||
---
|
||||
|
||||
### 连接失败怎么办?
|
||||
## 许可说明
|
||||
|
||||
请先查看 [使用说明](./docs/user-guide/getting-started.md) 的“遇到问题”部分,重点确认微信数据目录、微信登录状态、微信版本和 macOS SIP 设置。
|
||||
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件。
|
||||
|
||||
</details>
|
||||
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
|
||||
|
||||
## ⚠️ 免责声明
|
||||
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议,并仅处理你有权访问的数据。
|
||||
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&theme=dark&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
</picture>
|
||||
</a>
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
<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>
|
||||
|
||||
## 💬 交流与反馈
|
||||
|
||||
请先完成 [第一次使用与问题排查](./docs/user-guide/getting-started.md),再查看问题排查和 FAQ。只有自助排查仍无法解决时,再扫码进入交流群。
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流与售后群二维码" width="280" />
|
||||
<b>TraceMemo(迹忆)</b>
|
||||
<br />
|
||||
把微信聊过的事,找回来、问清楚、留下来。
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# TraceMemo 文档
|
||||
|
||||
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。
|
||||
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。
|
||||
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。
|
||||
|
||||
## 你可以完成的任务
|
||||
|
||||
- [建立本地知识库](./user-guide/knowledge.md)
|
||||
- [生成群聊日报和总结](./user-guide/report.md)
|
||||
- [实验性:自托管微信分享卡片](./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)
|
||||
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
|
||||
- [数据、隐私与安全](./user-guide/privacy.md)
|
||||
- [常见问题与排查](./user-guide/troubleshooting.md)
|
||||
|
||||
## 如果你想了解 AI 为什么这样回答
|
||||
|
||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。
|
||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。
|
||||
|
||||
## 微信机器人和外部 Agent
|
||||
|
||||
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
|
||||
|
||||
| 你想做什么 | 应该看哪里 |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 在微信里给机器人发消息,让本机读取数据、生成总结并回复 | [Agent Hub](./agent/agent-hub.md) |
|
||||
| 在 Codex、Claude Code、OpenClaw 等外部 Agent 中主动查询过去的微信数据 | [Reader Skill](./agent/reader-skill.md) + [Local HTTP API](./agent/api.md) |
|
||||
|
||||
### 在微信里提问
|
||||
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”后扫码登录微信机器人。机器人收到文字消息后,可以查询最近会话、读取联系人聊天、生成群聊总结图片或总结群成员发言,并把结果回复给发消息的人。它需要本地微信数据库已经连接;依赖 AI 的任务还需要配置 AI 服务。
|
||||
|
||||
- [Agent Hub](./agent/agent-hub.md):连接机器人、查看运行状态和了解实时交互边界。
|
||||
|
||||
### 让外部 Agent 查询历史微信
|
||||
|
||||
连接 Reader Skill 后,你可以询问:
|
||||
|
||||
> “总结今天技术交流群讨论了什么。”
|
||||
> “过去一周有没有人提到这个项目?”
|
||||
|
||||
- [Agent 接入概览](./agent/overview.md):先选择适合你的接入方式。
|
||||
- [Reader Skill](./agent/reader-skill.md):安装并让外部 Agent 按需读取聊天。
|
||||
- [Local HTTP API](./agent/api.md):完整端点和请求示例。
|
||||
- [API 安全](./agent/api-security.md):Bearer Token、CORS、轮换和边界。
|
||||
|
||||
## 开发与平台
|
||||
|
||||
- [macOS 数据访问说明](./platform/macos.md)
|
||||
- [开发、测试与构建](./development/overview.md)
|
||||
- [本地启动排障](./development/local-startup-troubleshooting.md)
|
||||
- [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md)
|
||||
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
|
||||
|
||||
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 在微信里向 TraceMemo 提问(Agent Hub)
|
||||
|
||||
Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导航中的“Agent”页面。你先扫码登录一个微信机器人账号,再用微信账号向机器人发送文字;本机 Agent Hub 会接收消息、读取已经连接的微信数据,必要时调用已配置的 AI,再把结果回复给发送者。
|
||||
|
||||
普通用户不需要安装 Reader Skill,也不需要配置 API Token。先连接微信数据库,再扫码登录机器人即可开始;需要总结或自然语言理解的任务还要配置 AI Provider。
|
||||
|
||||
它和 Reader Skill 是两条不同的路径:
|
||||
|
||||
- Reader Skill / Local HTTP API:外部 Agent 主动查询历史微信数据;
|
||||
- Agent Hub / 微信机器人:机器人收到实时消息后处理并回复。
|
||||
|
||||
## 连接器和 Agent Hub 是什么关系
|
||||
|
||||
你不需要单独部署这些组件。扫码后,后台的微信连接器负责登录机器人、保持连接、接收微信消息和发送回复;Agent Hub 负责判断消息要做什么、查询 TraceMemo 本地数据、调用 AI 并组织结果。可以把它理解为:连接器负责“和微信通信”,Hub 负责“处理任务”。
|
||||
|
||||
## 你能做什么
|
||||
|
||||
连接 Agent Hub 后,可以在微信中询问:
|
||||
|
||||
- “最近 5 个会话”;
|
||||
- “帮我看看最近跟某人聊了些什么。”
|
||||
- “生成产品交流群今天的群聊总结图片。”
|
||||
|
||||
当前已实现的实时任务包括:
|
||||
|
||||
- 查看最近会话(数量限制为 1–20);
|
||||
- 查询你和某位联系人的近期聊天;
|
||||
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
|
||||
- 生成今天、昨天或近 7 天的群聊总结图片;
|
||||
- 总结指定群成员在群里的近期发言;
|
||||
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
|
||||
|
||||
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
|
||||
|
||||
这些任务会在后台查询联系人、群聊和聊天记录,但当前机器人没有单独的“列出所有联系人”或“列出所有群聊”命令;需要完整浏览或按条件查询时,请使用档案页面或 Reader Skill / Local HTTP API。
|
||||
|
||||
## 连接步骤
|
||||
|
||||
1. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
|
||||
2. 确认 Hub 显示“运行中”,数据库状态为“可查询”。
|
||||
3. 点击“扫码登录微信机器人”。
|
||||
4. 用微信扫描二维码;如果页面显示“已扫码,等待手机确认”,在手机上确认。
|
||||
5. 状态变为“在线”后,用另一个微信账号向机器人发送测试问题。
|
||||
|
||||
可以重新扫码登录或断开连接。登录凭证失效时,需要重新扫码。
|
||||
|
||||
## 运行日志
|
||||
|
||||
Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支持筛选、复制和清空,并会隐藏 Token 和二维码数据,不记录微信密码。
|
||||
|
||||
## 需要满足的条件
|
||||
|
||||
- TraceMemo 的微信数据库已经连接,并且数据 API 可以查询;
|
||||
- 依赖总结或自然语言理解的任务,需要在“设置 → AI 模型”配置可用的 AI 服务;
|
||||
- TraceMemo 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
|
||||
|
||||
## 安全与边界
|
||||
|
||||
- Hub 使用本机通信,不把数据库直接暴露到公网;
|
||||
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
|
||||
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
|
||||
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
|
||||
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
|
||||
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
|
||||
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
|
||||
|
||||
## 无法连接时
|
||||
|
||||
先检查 Hub、连接器和数据库三项状态,再查看日志。二维码过期、连接器不存在、凭证失效和数据 API 未就绪分别需要重新扫码、修复安装、重新登录或先完成微信数据库连接。
|
||||
@@ -0,0 +1,52 @@
|
||||
# Local HTTP API 安全
|
||||
|
||||
## 当前安全边界
|
||||
|
||||
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
|
||||
|
||||
## Bearer Token
|
||||
|
||||
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
|
||||
|
||||
- `/api/v1/health` 是公开健康检查;
|
||||
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
||||
- Token 由应用生成,使用 32 个随机字节编码;
|
||||
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
|
||||
- 文件权限设置为 `0600`;
|
||||
- 在“API Center”中可以显示、复制和重新生成;
|
||||
- 重新生成后旧 Token 立即失效。
|
||||
|
||||
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
||||
|
||||
```bash
|
||||
export TRACEMEMO_API_TOKEN="<TOKEN>"
|
||||
```
|
||||
|
||||
## CORS 与 Origin
|
||||
|
||||
带浏览器 `Origin` 的请求只允许精确的 HTTP loopback Origin:
|
||||
|
||||
- `http://localhost` 及其端口;
|
||||
- `http://127.0.0.1` 及其端口;
|
||||
- `http://[::1]` 及其端口。
|
||||
|
||||
不带 `Origin` 的 curl、Node、本地脚本和 Agent 请求不受浏览器 CORS 规则限制,但仍必须携带 Token(health 除外)。
|
||||
|
||||
## 不要做的事
|
||||
|
||||
- 不要把 Token 放入 URL query、日志、截图、公开 Skill 或 Git;
|
||||
- 不要把服务反向代理到公网;
|
||||
- 不要把“health 能访问”误认为数据端点无需授权;
|
||||
- 不要把 Bearer Token 当成跨用户权限系统;当前服务没有细粒度 Scope;
|
||||
- 不要在共享机器上让不可信进程继承 Token 环境变量。
|
||||
|
||||
## Token 不可用时
|
||||
|
||||
如果系统安全存储不可用,API Token 会无法生成或读取,本地 API 会安全停用。先修复系统钥匙串/凭据服务,再回到 API Center 重试。不要手动编辑 `local-api-token.bin`。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Agent 接入概览](./overview.md)
|
||||
- [Reader Skill](./reader-skill.md)
|
||||
- [数据、隐私与安全](../user-guide/privacy.md)
|
||||
- [v2.1.9 鉴权迁移说明](./release-notes-v2.1.9.md)
|
||||
@@ -0,0 +1,96 @@
|
||||
# TraceMemo Local HTTP API
|
||||
|
||||
本文面向需要自己写集成的开发者。普通用户请先阅读[Agent 接入概览](./overview.md)。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- 默认地址:`http://127.0.0.1:6131`
|
||||
- API 前缀:`/api/v1`
|
||||
- 默认只监听 loopback;不要把它当作公网服务。
|
||||
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`。
|
||||
- 请求体使用 JSON;响应为 JSON。
|
||||
|
||||
## 最小请求
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 读取数据
|
||||
export TRACEMEMO_API_TOKEN="<从 API Center 复制的 Token>"
|
||||
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||
```
|
||||
|
||||
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
||||
|
||||
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
|
||||
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
||||
|
||||
### 这些端点与实时机器人有什么关系
|
||||
|
||||
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
|
||||
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
|
||||
- `/api/v1/agent/send` 是受 Bearer Token 保护的开发者/测试发送入口,用于通过已经连接的机器人发送文字或本地图片;它不是任意群发能力,也不是实时消息订阅接口;
|
||||
- 当前 API 没有对外暴露实时入站 webhook。微信消息由应用内部的 Agent Hub 和微信连接器接收、处理和回复。
|
||||
|
||||
## 时间查询
|
||||
|
||||
`chatlog` 的 `time` 支持:
|
||||
|
||||
- `YYYY-MM-DD`:当天;
|
||||
- `YYYY-MM-DD~YYYY-MM-DD`:日期闭区间;
|
||||
- `YYYY-MM-DD/HH:mm`:从该分钟开始的 60 秒;
|
||||
- 也可以使用 Unix 秒级 `startTime` 和 `endTime`。
|
||||
|
||||
时间按运行 TraceMemo 的本机时区解析。用户说“今天”“昨天”时,先调用 `current_time`,再根据返回的 `localDate` 计算日期,避免使用 Agent 自己的时区。
|
||||
|
||||
## 常用工作流
|
||||
|
||||
### 查找并读取一个会话
|
||||
|
||||
```bash
|
||||
BASE="http://127.0.0.1:6131/api/v1"
|
||||
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
|
||||
|
||||
curl -H "$AUTH" "$BASE/resolve?q=技术交流群"
|
||||
curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
```
|
||||
|
||||
当标识不确定时,先用 `resolve` 或 `contact`,再调用 `chatlog`。对重要问题,先宽范围定位,再针对关键时间点读取前后文,不要只凭一次粗查回答。
|
||||
|
||||
### 生成群聊总结图片
|
||||
|
||||
优先使用 `/api/v1/agent/group-report`,因为它会读取指定群聊并按 `today`、`yesterday` 或 `7days` 生成总结。`/api/v1/report` 是更底层的渲染接口,要求调用方已经准备好 `report` 和 `metadata` 结构;完整 TypeScript 类型以 `src/shared/group-report.ts` 为准。
|
||||
|
||||
## 响应与错误
|
||||
|
||||
- `200`:请求成功;
|
||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||
- `400`:参数或 JSON 请求体无效;
|
||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||
- `404`:端点、会话或群聊不存在;
|
||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||
- `500`:服务端处理或报告渲染失败。
|
||||
|
||||
成功响应会返回端点对应的 JSON 对象,例如 `chatlog` 包含 `contact`、`query`、`count` 和 `messages`,`contact` 返回 `count` 与 `contacts`。
|
||||
|
||||
## 与 MCP 的关系
|
||||
|
||||
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 在微信机器人或外部 Agent 中使用 TraceMemo
|
||||
|
||||
TraceMemo 提供两条不同路径。先按你实际想做的事选择,不需要先理解 Agent、Skill 或 API 等术语。
|
||||
|
||||
| 你想做什么 | 使用方式 | 需要什么 |
|
||||
| ---------------------------------------------------- | ----------------------------- | --------------------------------------------------------- |
|
||||
| 直接在微信里发文字,让本机查询聊天并回复 | 微信机器人(Agent Hub) | 在应用“Agent”页面扫码登录机器人;部分任务需要 AI Provider |
|
||||
| 在 Codex、Claude Code、OpenClaw 等工具里查询微信历史 | Reader Skill + Local HTTP API | 安装 Skill,并配置本机 API Token |
|
||||
|
||||
## 直接在微信里提问
|
||||
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”,扫码登录一个微信机器人账号。之后用另一个微信账号向机器人发送文字,它会调用 TraceMemo 的本机数据,必要时使用已配置的 AI,再把结果回复给发送者。
|
||||
|
||||
可以先尝试:
|
||||
|
||||
- “最近 5 个会话”;
|
||||
- “帮我看看最近跟张三聊了些什么”;
|
||||
- “生成产品交流群今天的群聊总结图片”。
|
||||
|
||||
这条路径不要求安装 Reader Skill,也不要求用户配置 API Token。它主要处理文字请求,不支持群发、定时任务或与文字同等的任意媒体理解。
|
||||
|
||||
连接步骤、当前任务清单和安全边界见[Agent Hub](./agent-hub.md)。
|
||||
|
||||
## 在外部 Agent 中查询历史微信
|
||||
|
||||
Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以通过 TraceMemo Local HTTP API 按需读取联系人、群聊、最近会话、指定时间范围的聊天和群成员信息。
|
||||
|
||||
典型问题包括:
|
||||
|
||||
- “总结今天技术交流群讨论的内容。”
|
||||
- “帮我找上个月讨论过的项目地址。”
|
||||
- “过去一周有没有人提到退款?”
|
||||
|
||||
外部 Agent 不会直接打开微信数据库文件,但它能取得本机 API 返回的聊天内容。Agent 是否继续把结果发送给云端模型,取决于 Agent 自己的模型和工具配置。
|
||||
|
||||
## 外部 Agent 的安装步骤
|
||||
|
||||
1. 启动 TraceMemo 并完成微信数据库连接。
|
||||
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
|
||||
3. 选择目标 Agent,点击“复制安装指令”。
|
||||
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
|
||||
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `TRACEMEMO_API_TOKEN`。
|
||||
6. 先让 Agent 调用 health,再尝试查询最近会话。
|
||||
|
||||
详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。
|
||||
|
||||
## 不要混淆两条路径
|
||||
|
||||
- Agent Hub:微信机器人收到实时文字后处理并回复;
|
||||
- Reader Skill/API:外部 Agent 主动查询历史数据;
|
||||
- `127.0.0.1:6131` 是 Local HTTP API,不是 MCP Server;
|
||||
- Local HTTP API 当前没有对外提供实时入站消息订阅。
|
||||
@@ -0,0 +1,62 @@
|
||||
# Reader Skill:让外部 Agent 读取微信
|
||||
|
||||
## 先理解它能做什么
|
||||
|
||||
Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以按需调用 TraceMemo,读取联系人、群聊、最近会话、指定时间的聊天和群成员信息。
|
||||
|
||||
它使用的是 TraceMemo Local HTTP API,不是 MCP Server。
|
||||
|
||||
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
|
||||
|
||||
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
|
||||
|
||||
## 推荐安装流程
|
||||
|
||||
1. 启动 TraceMemo 并完成数据库连接。
|
||||
2. 打开“API Center”,确认 API 服务和数据库状态正常。
|
||||
3. 在 Reader Skill 区域选择目标 Agent,点击“复制安装指令”。
|
||||
4. 把指令粘贴到对应 Agent 的 Skill/配置目录;应用会根据本机路径生成适合 Codex、Claude Code、OpenClaw 或通用 Agent 的说明。
|
||||
5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置:
|
||||
|
||||
```bash
|
||||
export TRACEMEMO_API_TOKEN="<YOUR_API_TOKEN>"
|
||||
```
|
||||
|
||||
6. 先执行 health 检查,再读取数据端点。
|
||||
|
||||
TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
|
||||
|
||||
## Agent 的读取顺序
|
||||
|
||||
当用户使用“今天”“昨天”“本周”等相对时间时:
|
||||
|
||||
1. 调用 `/api/v1/current_time` 获取本机时区和日期;
|
||||
2. 将相对时间换算为 `chatlog` 支持的 `time` 或时间戳;
|
||||
3. 调用 `/api/v1/resolve`、`contact` 或 `chatroom` 确认会话;
|
||||
4. 调用 `/api/v1/chatlog` 读取目标范围;
|
||||
5. 对重要结论再读取关键消息前后文,不要只凭一次粗查。
|
||||
|
||||
## 最小请求
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||
```
|
||||
|
||||
## 当前能力范围
|
||||
|
||||
Reader Skill 可以指导 Agent 使用:
|
||||
|
||||
- 联系人、群聊、最近会话和会话解析;
|
||||
- 指定会话、日期或时间戳范围的聊天记录;
|
||||
- 群成员快照;
|
||||
- 结构化日报渲染和按群聊生成总结图片;
|
||||
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
|
||||
|
||||
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
|
||||
|
||||
## 隐私边界
|
||||
|
||||
Reader Skill 本身不会把聊天数据自动上传到其他服务器;它只是让 Agent 调用本机 API。Agent 读取结果是否继续发送给云端模型,取决于 Agent 自己的模型和工具配置。请同时阅读[数据、隐私与安全](../user-guide/privacy.md)。
|
||||
@@ -0,0 +1,12 @@
|
||||
# TraceMemo 2.1.9:Local HTTP API 鉴权迁移
|
||||
|
||||
2.1.9 为 Local HTTP API 增加 Bearer Token 鉴权。这是一次有意的兼容性变化:除健康检查外,数据接口不再接受裸请求。
|
||||
|
||||
- 历史版本中,`GET /api/v1/contact` 等数据请求可能直接返回内容;
|
||||
- 2.1.9 中,相同请求必须携带 `Authorization: Bearer <TOKEN>`,否则返回 `401`;
|
||||
- `GET /api/v1/health` 保持公开;
|
||||
- 升级后应用会生成并安全保存 Token,原有 API 启用状态、监听地址和端口设置保持不变;
|
||||
- Token 可在 TraceMemo → API Center 中显示、复制和重新生成;
|
||||
- Reader Skill、Codex、Claude Code、OpenClaw 和其他本地 Agent 需要在自己的环境中设置 `WECHATEXPLORER_API_TOKEN`。
|
||||
|
||||
如果旧 Agent 无法访问,请先从 API Center 复制当前 Token,再确认每个非 health 请求都带有 Bearer header。完整规则见[API 安全](./api-security.md)。
|
||||
@@ -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)。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 如何核对 AI 的回答来源
|
||||
|
||||
## 先记住一件事
|
||||
|
||||
AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内容来自哪个会话和时间,并跳回原始消息检查上下文。
|
||||
|
||||
这让 TraceMemo 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
|
||||
|
||||
## 三类来源信息
|
||||
|
||||
在产品界面和检索详情中,你可能看到这些名称:
|
||||
|
||||
- **Evidence**:AI 回答所依据的原始聊天片段。
|
||||
- **Citation**:回答中某个结论对应的来源标记。
|
||||
- **Search Trace**:本次查找经历了哪些阶段、每一步用了多久、覆盖是否完整。
|
||||
|
||||
普通用户不需要记住英文名。判断一个回答是否可信时,按“来源 → 原消息 → 上下文”检查即可。
|
||||
|
||||
## 推荐的核对顺序
|
||||
|
||||
1. 先看回答是否明确区分事实、推断和不确定信息;
|
||||
2. 打开来源,检查发送者、会话和时间;
|
||||
3. 跳回档案,查看消息前后文,确认是否存在引用、转发或后续修正;
|
||||
4. 检查提示中是否有未转写语音、缺失媒体或只覆盖部分范围;
|
||||
5. 对重要决定、金额、日期和责任人,不要只依据 AI 摘要。
|
||||
|
||||
## 为什么来源可能不完整
|
||||
|
||||
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
|
||||
|
||||
- Knowledge 正在同步时,新的分析会被暂停;
|
||||
- 语音没有转写时,AI 可能只能看到消息类型;
|
||||
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
|
||||
- 你只选择了一个群,答案不会自动代表所有聊天。
|
||||
|
||||
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
|
||||
|
||||
## 这不是事实保证
|
||||
|
||||
Evidence 和 Citation 能告诉你“模型看到了什么”,不能保证模型没有误读。最终判断仍应回到原始消息,尤其是涉及隐私、法律、财务、医疗或工作决策时。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# TraceMemo 如何把聊天变成可用的信息
|
||||
|
||||
你可以把一次任务想成下面这条路径:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[本机微信数据] --> B[读取与解析]
|
||||
B --> C[聊天档案与普通搜索]
|
||||
B --> D[本地知识索引]
|
||||
D --> E[筛选相关消息]
|
||||
E --> F[用户配置的 AI Provider]
|
||||
F --> G[回答与可核对来源]
|
||||
B --> H[聊天导出]
|
||||
B --> I[整理日报输入]
|
||||
I --> F
|
||||
F --> J[本地保存 HTML 与 PNG]
|
||||
B --> K[Local HTTP API]
|
||||
K --> L[外部 Agent]
|
||||
M[微信机器人消息] --> N[Agent Hub]
|
||||
N --> B
|
||||
N --> F
|
||||
```
|
||||
|
||||
## 哪些步骤在本机
|
||||
|
||||
- 微信数据库读取与解析;
|
||||
- 聊天档案浏览和普通搜索;
|
||||
- Knowledge 索引与增量同步;
|
||||
- 离线语音转写;
|
||||
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
|
||||
|
||||
## 哪些步骤可能调用外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
|
||||
|
||||
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
|
||||
|
||||
如果 Provider 是 Ollama 等本机服务,请把它视为本机的另一个进程;如果是云服务,数据处理和留存规则由该服务商决定。
|
||||
|
||||
## 产品名词和用户任务的对应关系
|
||||
|
||||
| 用户想做什么 | 产品中可能看到的名称 |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
||||
| 让答案能回到原消息 | Evidence、Citation |
|
||||
| 查看 AI 查找过程 | Search Trace |
|
||||
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
||||
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
||||
| 让微信机器人调用本机能力 | Agent Hub |
|
||||
|
||||
先按任务使用,再在需要排查或开发集成时阅读术语。
|
||||
@@ -0,0 +1,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、微信数据路径或聊天内容。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 开发、测试与构建
|
||||
|
||||
本文面向希望参与 TraceMemo 开发、验证文档或维护集成的贡献者。普通用户请从[第一次使用](../user-guide/getting-started.md)开始。
|
||||
|
||||
## 技术基线
|
||||
|
||||
- Electron + React + TypeScript;
|
||||
- pnpm 7+;
|
||||
- Go(构建微信连接器);
|
||||
- 平台对应的 Electron/native 构建环境。
|
||||
|
||||
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||
|
||||
常用检查:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
|
||||
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。
|
||||
|
||||
## 代码变更对应文档
|
||||
|
||||
| 代码区域 | 需要同步检查的文档 |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
|
||||
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
|
||||
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
|
||||
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
||||
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
||||
| `src/main/services/recall-archive-service.ts`、防撤回设置 | `user-guide/recall-protection.md`、`user-guide/privacy.md` |
|
||||
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
||||
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
||||
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
|
||||
|
||||
## 文档检查
|
||||
|
||||
提交文档变更前至少执行:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!DOCUMENTATION_AUDIT.md' --glob '!development/overview.md'
|
||||
```
|
||||
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
||||
+3
-45
@@ -1,47 +1,5 @@
|
||||
# macOS 关闭 SIP 教程
|
||||
# macOS 数据访问说明(兼容入口)
|
||||
|
||||
SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
|
||||
完整内容已移到[macOS 数据访问与系统权限](./platform/macos.md)。
|
||||
|
||||
## 准备
|
||||
|
||||
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
|
||||
- 需要进入 macOS 恢复模式。
|
||||
- 请先保存正在编辑的文件,并预留一次重启时间。
|
||||
|
||||
## 关闭 SIP
|
||||
|
||||
### Intel Mac
|
||||
|
||||
1. 关机。
|
||||
2. 按下开机键后,立刻按住 `Command + R`。
|
||||
3. 保持按住,直到进入 macOS 恢复模式。
|
||||
|
||||
### Apple Silicon Mac(M1/M2/M3/M4)
|
||||
|
||||
1. 关机。
|
||||
2. 长按开机键不放。
|
||||
3. 直到出现启动选项界面后松开。
|
||||
4. 选择“选项”,进入 macOS 恢复模式。
|
||||
|
||||
### 在恢复模式中执行命令
|
||||
|
||||
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**。
|
||||
2. 选择 **Terminal(终端)**。
|
||||
3. 在终端中输入:
|
||||
|
||||
```bash
|
||||
csrutil disable
|
||||
```
|
||||
|
||||
4. 按回车执行。
|
||||
5. 看到关闭成功提示后,重启电脑。
|
||||
|
||||
## 重新开启 SIP
|
||||
|
||||
如果后续不再需要关闭 SIP,建议重新进入恢复模式,在终端中执行:
|
||||
|
||||
```bash
|
||||
csrutil enable
|
||||
```
|
||||
|
||||
然后重启电脑。
|
||||
保留此文件是为了兼容应用内已经发布的帮助链接。请不要把“关闭 SIP”当作默认安装步骤;只有当当前连接页面明确要求时才处理,并在完成后恢复系统安全设置。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# macOS 数据访问与系统权限
|
||||
|
||||
## 你什么时候会看到这些提示
|
||||
|
||||
TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
|
||||
|
||||
## 推荐步骤
|
||||
|
||||
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
|
||||
2. 确认微信数据目录指向当前账号。
|
||||
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
||||
4. 返回应用重新检测账号、数据库和图片资源状态。
|
||||
|
||||
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
|
||||
|
||||
## SIP 风险
|
||||
|
||||
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 TraceMemo 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
|
||||
|
||||
## 应用无法打开
|
||||
|
||||
如果 macOS 阻止未验证的应用,使用系统“隐私与安全性”中的“仍要打开”选项。不要为了绕过提示下载来历不明的补丁或替换应用文件。
|
||||
|
||||
## Intel 与 Apple Silicon
|
||||
|
||||
从 Releases 选择与 Mac 处理器匹配的构建。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
||||
|
||||
@@ -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,355 +0,0 @@
|
||||
---
|
||||
name: wechatexplorer-reader
|
||||
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app。
|
||||
---
|
||||
|
||||
# WechatExplorer Reader
|
||||
|
||||
通过本地 HTTP API(`http://127.0.0.1:6131`)读取 WechatExplorer 已经解锁的微信数据库内容。
|
||||
|
||||
## 数据源
|
||||
|
||||
- **本服务由 WechatExplorer.app 提供**,数据完全在本地处理,不会上传任何服务器
|
||||
- 用户必须在 WechatExplorer 主窗口完成**首次密钥配置**(解锁 WCDB 数据库)
|
||||
- 默认监听 `127.0.0.1:6131`,仅本机可访问,无需鉴权
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **安装并启动 WechatExplorer.app**(从项目 release 页面下载)
|
||||
2. **首次启动时完成密钥配置**:在主界面第一步输入微信数据库密钥(64 位 hex),完成 WCDB 初始化
|
||||
3. **如需 7×24 提供 API**:用 `WXE_TRAY=1` 或 `--tray` 参数启动 app,启用菜单栏常驻模式(主窗口关闭后服务仍在)
|
||||
|
||||
## API 列表
|
||||
|
||||
GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图)。所有端点返回 JSON。
|
||||
|
||||
| 端点 | 用途 | 关键参数 |
|
||||
|------|------|---------|
|
||||
| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — |
|
||||
| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) |
|
||||
| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `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 解析成 md5 | `q` |
|
||||
| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) |
|
||||
|
||||
### `talker` 参数可接受的值
|
||||
|
||||
`chatlog` 和 `recent_chat` 的 `talker` / 列表项 ID 支持以下三种形式,服务端会按 `nickname → wxid → md5` 顺序匹配:
|
||||
|
||||
1. **群昵称 / 好友备注**(模糊匹配,如 `技术交流`、`摸鱼群`)
|
||||
2. **微信 wxid**(如 `wxid_abc123`、`gh_xxxxx@chatroom`)
|
||||
3. **会话 md5**(如 `49023470180@chatroom` 的 md5 哈希,可在 `contact` 接口里看到)
|
||||
|
||||
不确定时先调 `GET /api/v1/resolve?q=<输入>` 校验,返回 `{ md5, m_nsUsrName, m_nsNickName, type, ... }`。
|
||||
|
||||
### `chatroom` 与 `contact?type=group` 字段一致性
|
||||
|
||||
`/chatroom` 和 `/contact?type=group` 返回的是**同一个集合**(都是 `listContacts().filter(type==='group')`),字段也完全一致:
|
||||
|
||||
```json
|
||||
{
|
||||
"m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker
|
||||
"m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer
|
||||
"type": "group",
|
||||
"md5": "..."
|
||||
}
|
||||
```
|
||||
|
||||
需要 `displayName` 时从 `m_nsNickName` 里解析;需要拉消息就传 `m_nsUsrName` 当 talker。
|
||||
|
||||
## 时间范围格式(`time` 参数)
|
||||
|
||||
支持以下格式:
|
||||
|
||||
| 输入 | 含义 |
|
||||
|------|------|
|
||||
| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 |
|
||||
| `2026-07-01~2026-07-03` | 日期范围(闭区间) |
|
||||
| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) |
|
||||
| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 |
|
||||
|
||||
也可以直接传 unix 秒级时间戳作为 `startTime` 和 `endTime`。
|
||||
|
||||
### "今天 / 昨天 / 本周" 的时区语义
|
||||
|
||||
所有 `time` / `startTime` / `endTime` 都按**用户本机时区**解析(由 `current_time` 里的 `timezone` 字段给出,典型为 `Asia/Shanghai`)。含义如下:
|
||||
|
||||
- "今天 2026-07-03" → 本机 2026-07-03 00:00:00 ~ 23:59:59(北京时间 24 小时),**不是** UTC 当天
|
||||
- "昨天" → 本机昨天 0 点 ~ 23:59:59
|
||||
- "本周" → 本周一 0 点 ~ 当前时刻(按本机时区所在周的周一)
|
||||
|
||||
跨时区时(如用户在国外):仍以本机时区为准,需要按 UTC 处理时显式传 unix 时间戳。
|
||||
|
||||
## 时间预检工作流(Time-Aware Workflow)
|
||||
|
||||
**重要**:只要用户请求中包含"今天"、"昨天"、"本周"、"刚才"等相对时间概念,**禁止**直接生成日期字符串。
|
||||
|
||||
**步骤 1**:先调用 `current_time` 工具获取本地 RFC3339 时间。
|
||||
**步骤 2**:根据返回的时间计算对应的 `time` 参数。
|
||||
**步骤 3**:用计算后的参数调 `chatlog`。
|
||||
|
||||
示例:
|
||||
- 用户: "今天 摸鱼交流群 聊了啥?"
|
||||
- AI: 先 `GET /api/v1/current_time` → 得到 `2026-07-03T14:30:00+08:00` → 计算 `time=2026-07-03` → `GET /api/v1/chatlog?talker=摸鱼交流群&time=2026-07-03`
|
||||
|
||||
## 多步上下文检索(强制)
|
||||
|
||||
当查询特定话题或特定发送者发言时,**必须**按以下流程操作:
|
||||
|
||||
1. **初步定位**:用 `contact` 或 `chatroom` 端点确定群聊 md5 / wxid
|
||||
2. **粗查**:用 `chatlog` + 较宽时间范围找到相关消息时间点
|
||||
3. **精查**:对每个关键时间点分别查前后 15-30 分钟(不带任何 keyword 过滤),用完整上下文分析
|
||||
|
||||
**禁止**:仅凭一次粗查结果直接回答用户。
|
||||
|
||||
## 生成群日报(POST /api/v1/report)
|
||||
|
||||
当用户希望输出**可视化群日报**(长图 PNG + HTML 邮件版)时,用这个端点。WechatExplorer 内置 `mobile_daily_report.html` 模板,渲染后会同时落盘 `htmlPath` 和 `pngPath`,并返回 `imageDataUrl` 可直接预览。
|
||||
|
||||
### 请求体(`GroupReportExportRequest`)
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"overview": "一句话总览,20-80 字",
|
||||
"topics": [
|
||||
{
|
||||
"title": "话题标题",
|
||||
"timeRange": "10:00-12:30",
|
||||
"heat": "高", // "高" | "中" | "低"
|
||||
"participants": ["张三", "李四"],
|
||||
"summary": "本话题讨论了什么",
|
||||
"conclusion": "可选,达成的结论",
|
||||
"keywords": ["关键词1", "关键词2"]
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{ "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" }
|
||||
],
|
||||
"importantMessages": [
|
||||
{ "sender": "张三", "time": "10:23", "content": "原消息文本", "note": "为什么重要" }
|
||||
],
|
||||
"quotes": [
|
||||
{
|
||||
"messages": [{ "sender": "李四", "content": "原话1" }, { "sender": "王五", "content": "原话2" }],
|
||||
"note": "为什么这些话值得引用"
|
||||
}
|
||||
],
|
||||
"qa": [
|
||||
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
|
||||
],
|
||||
"unresolved": [
|
||||
{ "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
|
||||
],
|
||||
"storylines": [
|
||||
{ "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
|
||||
],
|
||||
"reversals": [
|
||||
{ "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
|
||||
],
|
||||
"participantChains": [
|
||||
{ "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
|
||||
],
|
||||
"analytics": {
|
||||
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
||||
"activeTimeline": "10:00-12:00 为最活跃时段",
|
||||
"topSpeakers": [{ "name": "张三", "count": 58 }],
|
||||
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
|
||||
},
|
||||
"keywords": ["高频词1", "高频词2"],
|
||||
"hero": {
|
||||
"headline": "一句抓重点的日报标题",
|
||||
"summary": "一句概览",
|
||||
"keyTakeaway": "最重要结论",
|
||||
"pendingNote": "待跟进事项"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
"reportDate": "2026-07-03",
|
||||
"dateRange": "2026-07-03 全天",
|
||||
"messageCount": 1234,
|
||||
"activeUsers": 56,
|
||||
"timeSpan": "00:00-23:59",
|
||||
"generatedAt": "2026-07-03 22:00",
|
||||
"recordNote": "本日报由 WechatExplorer 自动生成",
|
||||
"footerNote": "底部附加说明",
|
||||
"heroParticipants": ["张三", "李四"],
|
||||
"avatars": {},
|
||||
"talker": "技术交流",
|
||||
"timeRange": "2026-07-03"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(`GroupReportExportResult`)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"htmlPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.html",
|
||||
"pngPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.png",
|
||||
"imageDataUrl": "data:image/png;base64,iVBORw0K..."
|
||||
}
|
||||
```
|
||||
|
||||
成功返回 200;失败返回 500 + `{ success: false, error: "..." }`。HTML 和 PNG 用 `mobile_daily_report.html` 模板渲染,长图宽度自适应移动端预览。
|
||||
|
||||
### 典型工作流
|
||||
|
||||
1. 调 `current_time` + `chatlog` 拉取当天/目标时间段消息
|
||||
2. LLM 总结生成 `report` + `metadata`(直接走 AI 总结即可,无需自己造数据)
|
||||
3. POST 到 `/api/v1/report` 拿到 `htmlPath` / `pngPath`,把文件路径告诉用户即可在 Finder 打开
|
||||
4. **不要**自己拼 HTML/PNG,模板已内置,只需组织好 report/metadata 字段
|
||||
|
||||
### 必填字段与隐式约束(踩坑提示)
|
||||
|
||||
`metadata` 的以下字段**必填**,缺一返回 500:
|
||||
|
||||
- `groupName`、`reportDate`、`dateRange`、`generatedAt`
|
||||
- `heroParticipants`:数组,模板会把每个名字当 key 去 `metadata.avatars[name]` 取头像图
|
||||
- `avatars`:对象,**每个 `heroParticipants` 里的名字都必须有这个 key**(没有就传 `""`,**不要省略整段**),否则模板渲染会抛 `Cannot read properties of undefined (reading '<名字>')` 报 500
|
||||
|
||||
`report` 的以下字段**必须存在**(空就传 `[]`,**不能省略**),否则模板遍历时会抛 `Cannot read properties of undefined (reading 'map')` 报 500:
|
||||
|
||||
- `report.topics`(至少 1 个,完全没话题就改用纯文本总结,不要硬生成空日报)
|
||||
- `report.resources`
|
||||
- `report.importantMessages`
|
||||
- `report.quotes`
|
||||
- `report.qa`
|
||||
- `report.analytics.topicHeat`
|
||||
- `report.analytics.topSpeakers`(至少 1 个)
|
||||
- `report.keywords`
|
||||
|
||||
最小安全示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"overview": "...",
|
||||
"topics": [],
|
||||
"resources": [],
|
||||
"importantMessages": [],
|
||||
"quotes": [],
|
||||
"qa": [],
|
||||
"analytics": { "topicHeat": [], "activeTimeline": "", "topSpeakers": [] },
|
||||
"keywords": []
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
"reportDate": "2026-07-07",
|
||||
"dateRange": "2026-07-07 全天",
|
||||
"heroParticipants": ["张三", "李四"],
|
||||
"avatars": { "张三": "", "李四": "" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`report.importantMessages[].time` 用 `HH:mm` 格式(不要 ISO 时间戳);`report.analytics.topicHeat[].score` 数字 0-10。
|
||||
|
||||
### 4 个数字格子的内容必须紧凑(避免塌陷)
|
||||
|
||||
模板顶部的 4 个统计格(`消息数 / 活跃人数 / 时间跨度 / 主要话题`)宽度均分,内容过长会被截断或换行:
|
||||
|
||||
| 字段 | 推荐格式 | 反例(会撑爆格子) |
|
||||
|------|---------|----------------|
|
||||
| `metadata.messageCount` | 纯数字 `"1234"` | `"约 1.2k 条"` |
|
||||
| `metadata.activeUsers` | 纯数字 `"56"` | `"大约 50 多人"` |
|
||||
| `metadata.timeSpan` | **持续时长紧凑半角** `"1 h"` / `"30 min"` / `"2 d"` | `"1 小时"` / `"7 小时"` / `"1天3小时"` |
|
||||
| `metadata.topicCount` 等 | 数字 / 短中文 | 长句子 |
|
||||
|
||||
`timeSpan` 是**首条到末条消息的持续时长**,不是时间区间。**单位用半角空格分隔**:
|
||||
|
||||
- `< 1 h` → `"30 min"`
|
||||
- `1~24 h` → `"1 h"` / `"7 h"`(整数,向上取整)
|
||||
- `> 24 h` → `"2 d"`(整数,向上取整)
|
||||
|
||||
**首末条消息的具体时间点**:`dateRange` 字段会显示完整日期 + 起止时间(无长度限制),模板里 dateRange 是 hero 区的副标题,跟 stat 格子分开。
|
||||
|
||||
**区间叙事**(如"主要集中在上午 10 点-12 点")放 `report.analytics.activeTimeline`,那是模板里单独一段的描述,不被 stat 格子限制。
|
||||
|
||||
**不传 timeSpan**:服务端会用空字符串渲染(stat 格会空),subagent 应当总是算好时长填进来,或者 renderer 端会自动算(见 renderer 源码)。
|
||||
|
||||
### 头像:服务端自动反推(推荐)
|
||||
|
||||
**v1.4 起无需手动拼 `avatars` 字典**。在 `metadata` 里加 `talker`(群昵称/wxid/md5 都行),服务端会用 `getGroupSnapshot` 拉全量群成员,按 `nickname → avatar` 自动反推填进 `metadata.avatars`。LLM 总结里出现的 `heroParticipants` / `topics[].participants` / `topSpeakers[].name` 等所有名字都会被覆盖。
|
||||
|
||||
**优先级**:客户端传的 `avatars[name]`(非空字符串) > 服务端反推 > 占位 SVG(姓名首字母 + 随机色块)。
|
||||
|
||||
**回退**:不传 `talker` 时按 `metadata.avatars` 字典取;还取不到则生成 SVG 占位(`fallbackAvatar`),**不会变空白方块**(v1.4 修了 data URL 正则,SVG 占位能正常嵌入)。
|
||||
|
||||
**手动覆盖**:仍可传 `avatars` 字典强制使用自定义头像,例如 `{"张三": "data:image/jpeg;base64,..."}`。
|
||||
|
||||
**P2 风险**:群里有两人同名(如"杨伟")时,服务端只取首条;客户端可手动覆盖。
|
||||
|
||||
## 隐私安全原则
|
||||
|
||||
1. **最小化原则**:只返回用户明确请求的内容,不过度展开无关聊天
|
||||
2. **本地处理**:所有数据来自用户本机,API 不缓存、不转发
|
||||
3. **摘要优先**:对于大量聊天记录,先提供摘要而非完整 dump
|
||||
4. **用户确认**:涉及敏感内容时,先展示摘要,让用户决定是否继续深入
|
||||
|
||||
## 典型工作流示例
|
||||
|
||||
**示例 1:今日群聊总结(纯文本)**
|
||||
1. `GET /api/v1/current_time` → 获取今天日期
|
||||
2. `GET /api/v1/chatroom?keyword=技术交流` → 找到目标群 md5
|
||||
3. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉取今天的聊天
|
||||
4. AI 用 LLM 生成总结报告(话题 TOP N、最活跃发言者等)
|
||||
|
||||
**示例 2:搜索特定消息上下文**
|
||||
1. `GET /api/v1/chatlog?talker=摸鱼群&time=2026-07-01~2026-07-03` → 粗查近 3 天
|
||||
2. 在返回的消息中定位关键词出现的时间点 T1, T2, ...
|
||||
3. 对每个 Ti 分别查 `chatlog?talker=摸鱼群&time=Ti-15min~Ti+15min`,分析上下文
|
||||
|
||||
**示例 3:群日报(可视化长图)**
|
||||
1. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉今天聊天
|
||||
2. LLM 按上方 `GroupDailyReport` schema 总结出 `report` + `metadata`
|
||||
3. `POST /api/v1/report` body = 上述 JSON → 拿到 `htmlPath` / `pngPath` / `imageDataUrl`
|
||||
4. 把 `imageDataUrl` 给用户预览,把 `pngPath` 路径告诉用户用 Finder 打开
|
||||
|
||||
## 错误处理
|
||||
|
||||
- `503` → WechatExplorer 未初始化(密钥未配置),提示用户在主窗口完成配置
|
||||
- `404 talker not found` → talker 不存在,先调 `contact` 或 `resolve` 确认 md5/wxid
|
||||
- `400 missing required parameter` → 检查必填参数(talker / md5 / q)
|
||||
- `200` 但 `result.warnings: ['enrich skipped: talker "X" not found']` → `/report` 的 `metadata.talker` 解析失败,头像走 SVG fallback(不阻断生成)
|
||||
- `200` 但 `result.warnings: ['enriched N member avatars from snapshot (M members)']` → enrich 成功(诊断用)
|
||||
- `400 请求体为空 / 需包含 report 和 metadata` → 调用 `/report` 时 body 必须是非空 JSON,且有这两个顶层字段
|
||||
- `500 success=false` → 模板渲染失败,通常因 `report` 字段缺失或 `metadata.groupName/reportDate` 为空,检查后重试
|
||||
|
||||
## 配置 Claude Desktop
|
||||
|
||||
把以下加入 `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechatexplorer": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@wechatexplorer/mcp-bridge"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(待 P3 实现 — MCP bridge 包,在此之前可直接用 `curl` 调用 HTTP API,或通过 mcp-remote 桥接。)
|
||||
|
||||
## 配置 Claude Code / Codex
|
||||
|
||||
在 `~/.claude/settings.json` 或项目级 `.claude/settings.local.json` 中:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechatexplorer": {
|
||||
"url": "http://127.0.0.1:6131"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(视 MCP over HTTP 支持情况调整)
|
||||
+674
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2026 yincongcyincong
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# wechat_chatter / OneBot 第三方组件说明
|
||||
|
||||
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
|
||||
|
||||
- 项目:`yincongcyincong/wechat_chatter`
|
||||
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
|
||||
- 当前运行时版本:`v0.0.18`
|
||||
- 运行时文件:`onebot_mac_arm64.tar.gz`
|
||||
- 许可证:GNU General Public License version 3(GPL-3.0)
|
||||
- 上游版权:Copyright (C) 2026 yincongcyincong
|
||||
- TraceMemo 修改日期:2026-08-17
|
||||
|
||||
## 集成方式
|
||||
|
||||
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
|
||||
|
||||
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
|
||||
|
||||
## 本地修改
|
||||
|
||||
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
|
||||
|
||||
- `scripts/prepare-wechat-chatter-runtime.cjs`
|
||||
- `src/main/services/personal-wechat-runtime-manager.ts`
|
||||
|
||||
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
|
||||
|
||||
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 用 AI 查找你以前聊过的信息
|
||||
|
||||
## AI Search 是什么
|
||||
|
||||
你可以把它理解成“会帮你翻聊天记录的 AI”。
|
||||
|
||||
普通搜索需要你猜关键词;AI Search 更适合这些问题:
|
||||
|
||||
- “我们上个月为什么决定延期?”
|
||||
- “谁提过这个项目,后来结论是什么?”
|
||||
- “过去一周有哪些待跟进事项?”
|
||||
|
||||
它会先在本机查找相关聊天,再把受控范围内的内容交给你选择的 AI Provider 生成回答。它不是凭空记忆,也不是把整库聊天一次性上传。
|
||||
|
||||
## 第一次使用
|
||||
|
||||
1. 进入“设置 → AI 模型”,添加一个 Provider,填写服务地址、模型和认证信息,然后测试连接。
|
||||
2. 打开“问问微信”。
|
||||
3. 选择所有聊天、群聊、单聊或当前会话,并选择今天、近 7 天、近 30 天或不限时间;范围越明确,答案越容易核对。
|
||||
4. 输入问题并开始分析。
|
||||
|
||||
如果知识库尚未建立,页面会提示你建立或同步;你也可以先直接使用当前可用的搜索路径。
|
||||
|
||||
## 怎么提问更容易得到好结果
|
||||
|
||||
把“谁、什么时候、在哪个群、想找什么结果”写出来。例如:
|
||||
|
||||
> “在产品交流群里,查找 2026 年 7 月讨论发布延期的消息,列出结论和待办。”
|
||||
|
||||
尽量避免只写“总结一下”。如果你只记得模糊含义,也可以先提问,再根据来源缩小范围继续追问。
|
||||
|
||||
## AI 回答后先看什么
|
||||
|
||||
不要只看结论。回答区域通常还会展示:
|
||||
|
||||
- 参考了哪些聊天内容;
|
||||
- 来源来自哪个会话、发送者和时间;
|
||||
- 哪一段回答对应哪条来源;
|
||||
- 本次查找经过了哪些阶段、耗时和覆盖情况;
|
||||
- 是否存在未转写语音、媒体不可用或结果不完整的提示。
|
||||
|
||||
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||
|
||||
## 什么时候不要直接相信答案
|
||||
|
||||
- 来源很少,或时间范围与问题不一致;
|
||||
- 回答提到了来源中没有的细节;
|
||||
- 关键内容来自未转写语音、无法读取的图片或转发消息;
|
||||
- 页面提示只覆盖了部分聊天。
|
||||
|
||||
这些情况下,打开原消息,扩大或缩小范围,再重新提问。必要时把问题改成“只列出原文明确说过的内容”。
|
||||
|
||||
## 取消、失败和降级
|
||||
|
||||
分析过程中可以取消当前任务。检索或模型请求失败时,页面可能保留已找到的来源或切换到备用路径;这不代表一定得到了完整答案。请查看提示、检索详情和[排障文档](./troubleshooting.md#ai-没有结果或回答失败)。
|
||||
|
||||
## 数据会发到哪里
|
||||
|
||||
本地解析、索引和候选消息查找在本机完成。只有完成 AI 任务所需的用户问题、受控检索上下文和最终用于总结的来源内容,才可能发送到你配置的 Provider;具体边界见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
使用远程 Provider 时,当前界面会在本次请求发出前显示接收方和发送范围,等待你确认。当前实现最多发送 8 条最终来源,不会发送完整微信数据库、数据库密钥、绝对文件路径或内部会话/消息引用 ID;这次确认不会自动授权之后的其他请求。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 查看和搜索聊天
|
||||
|
||||
“档案”是你直接阅读微信历史的地方。适合查原文、回看上下文、确认 AI 来源,也适合在你已经知道关键词时快速定位。
|
||||
|
||||
## 选择要看的会话
|
||||
|
||||
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
||||
|
||||
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
|
||||
|
||||
## 普通关键词搜索什么时候最好用
|
||||
|
||||
当你记得以下任意信息时,优先使用档案搜索:
|
||||
|
||||
- 一段原话或关键词;
|
||||
- 人名、群名、项目名;
|
||||
- 链接、文件名或订单号;
|
||||
- 大致知道在哪个联系人或群里。
|
||||
|
||||
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
|
||||
|
||||
## 消息和媒体
|
||||
|
||||
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
|
||||
|
||||
不要把“消息类型已读取”理解成“所有媒体都一定能解码”。遇到图片或视频空白时,请先检查[媒体与导出排查](./troubleshooting.md#媒体显示或导出异常)。
|
||||
|
||||
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
|
||||
|
||||
## 可选保留撤回消息
|
||||
|
||||
“设置 → 防撤回”提供一个默认关闭的可选功能。开启后,应用会尽量保留之后捕获到的撤回消息,并在气泡旁标记“消息已撤回”。它不能找回开启前已经消失或应用未捕获到的内容,也可能增加加载开销。
|
||||
|
||||
该功能与普通只读浏览的数据边界不同。开启前请阅读[防撤回](./recall-protection.md)。
|
||||
|
||||
## 保护自己不被误导
|
||||
|
||||
档案中的原始消息是核对 AI 结果的最终依据。看到 AI 的总结、日报或来源时,建议:
|
||||
|
||||
1. 打开来源对应的会话;
|
||||
2. 查看消息前后几条上下文;
|
||||
3. 注意消息时间、发送者和是否存在转发/引用;
|
||||
4. 对未转写的语音、无法读取的图片保持不确定判断。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 会话列表为空
|
||||
|
||||
确认数据库连接成功、连接的是正确微信账号,并重新加载数据。若仍为空,查看[连接微信失败](./troubleshooting.md#连接微信失败)。
|
||||
|
||||
### 搜索不到明明存在的消息
|
||||
|
||||
先缩小到正确会话,再尝试更短的关键词或原文片段。对于“以前讨论过什么”这类语义问题,改用[AI Search](./ai-search.md)。
|
||||
|
||||
### 想跨多个会话查找
|
||||
|
||||
使用“问问微信”,并在问题中写清时间范围、人物或群聊范围。需要更稳定的跨会话查找时,先建立[本地知识库](./knowledge.md)。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 导出聊天档案
|
||||
|
||||
导出适合把微信里的重要讨论保存成可阅读、可分享或可继续处理的文件。
|
||||
|
||||
## 支持的格式
|
||||
|
||||
| 格式 | 适合什么任务 | 当前边界 |
|
||||
| -------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| HTML | 完整阅读和长期归档 | 可包含媒体、头像和可选语音转写;支持多会话、增量合并和 ZIP |
|
||||
| Markdown | 笔记、版本管理和再次编辑 | 主要保留文本内容,不复制 HTML 资源文件 |
|
||||
| CSV | 表格分析 | 主要保留文本内容,不复制 HTML 资源文件 |
|
||||
| JSON | 程序处理和数据归档 | 主要保留文本内容,不复制 HTML 资源文件 |
|
||||
|
||||
ZIP 是 HTML 资源包的压缩选项,不是第五种内容格式。
|
||||
|
||||
## 导出步骤
|
||||
|
||||
可以打开一级导航“导出”,也可以在“档案”的聊天顶部点击“导出”并选择时间范围。
|
||||
|
||||
1. 选择一个或多个联系人/群聊。
|
||||
2. 选择时间范围和消息类型。
|
||||
3. 选择格式;只有 HTML 可以配置媒体资源、语音转写和 ZIP。
|
||||
4. 按需要设置头像、原图/缩略图和缺失资源处理。
|
||||
5. 设置文件名并开始导出。
|
||||
6. 在导出任务中心查看读取、解析、媒体处理、转写、写入和压缩进度;完成后打开文件位置。
|
||||
|
||||
## 多会话和增量导出
|
||||
|
||||
HTML 支持把最多五个会话合并到一个档案中;选择多个会话后,其他格式会不可用。再次使用相同名称导出 HTML 时,可以把新消息增量合并到已有档案;这不会删除之前已导出的消息。
|
||||
|
||||
## 媒体怎么处理
|
||||
|
||||
原图、缩略图、缺失资源和头像都可能影响导出大小与可读性。想要小文件时关闭媒体或选择缩略图;想要长期保存时,确认原始媒体目录仍可访问,并考虑 ZIP 归档。
|
||||
|
||||
HTML 导出可以选择在任务中执行本地语音转写,并把成功结果显示在语音气泡下方。语音模型不可用或识别失败时,导出不会把失败内容当成已转写文本。
|
||||
|
||||
## 导出和原始数据的关系
|
||||
|
||||
导出是复制/整理结果,不会修改微信原始数据库。删除导出文件也不会影响应用内聊天记录或本地知识库。
|
||||
@@ -1,61 +1,54 @@
|
||||
# WechatExplorer:第一次使用与问题排查
|
||||
# 第一次使用 TraceMemo
|
||||
|
||||
这份说明解决三件事:第一次连接微信、连接成功后如何开始使用,以及遇到问题时如何自助排查。
|
||||
如果你刚下载 TraceMemo,只需要完成一条主线:
|
||||
|
||||
如果你已经进入软件,忘记了连接步骤,可以直接点击左下角「新手引导」,重新查看首次连接流程、AI 配置入口和群聊日报入口。
|
||||
> 安装应用 → 连接微信数据 → 确认聊天已加载 → 搜索或提问。
|
||||
|
||||
## 你现在要做什么
|
||||
这篇文档不要求你先学习内部术语;先把第一个问题问出来,之后再按需要深入了解产品名称和进阶功能。
|
||||
|
||||
- [我第一次使用,想连接微信](#第一次连接微信)
|
||||
- [我已经连接成功,下一步做什么](#连接成功后做什么)
|
||||
- [我想重新查看引导](#重新查看新手引导)
|
||||
- [我想配置 AI](#配置-ai)
|
||||
- [我遇到问题](#遇到问题)
|
||||
- [我想让 Agent 读取微信](#接入-api-reader-skill-或-agent)
|
||||
## 1. 开始前准备
|
||||
|
||||
> 正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录。应用缓存和本地设置可能随版本升级发生变化。系统故障、磁盘异常、误操作和微信自身迁移不受本应用控制,因此升级前仍建议使用微信官方迁移或备份功能备份重要聊天记录,不要将唯一副本保存在单一设备。
|
||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
|
||||
|
||||
## 开始前确认
|
||||
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
|
||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||
|
||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 已完整支持;首次使用时请确认微信数据目录 |
|
||||
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
||||
|
||||
- WechatExplorer 当前面向微信 4.0 数据结构。
|
||||
- Windows 不需要关闭 SIP。
|
||||
- macOS 首次自动获取数据库密钥需要按页面提示完成系统授权。
|
||||
- WechatExplorer 必须取得当前微信账号对应的数据库密钥才能读取聊天记录。
|
||||
- 请只处理你有权访问的微信数据。
|
||||
## 2. 安装并启动
|
||||
|
||||
WechatExplorer / 迹忆应用安装包:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。微信客户端请使用上方“已测试的微信客户端”链接。
|
||||
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
|
||||
|
||||
## 第一次连接微信
|
||||
### Windows
|
||||
|
||||
### 1. 安装 WechatExplorer
|
||||
|
||||
#### Windows
|
||||
|
||||
1. 从 Releases 下载 Windows `-setup.exe` 安装包。
|
||||
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。
|
||||
2. 双击安装包,按向导完成安装。
|
||||
3. 启动 WechatExplorer。
|
||||
3. 启动 TraceMemo。
|
||||
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
|
||||
|
||||
#### macOS
|
||||
### macOS
|
||||
|
||||
1. 从 Releases 下载 `.dmg` 文件。
|
||||
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. 如果这是第一次在 macOS 上自动获取数据库密钥,先完成 [关闭 SIP 教程](../mac-disable-sip.md)。关闭 SIP 会降低系统安全性,完成密钥配置后建议重新开启。
|
||||
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
|
||||
### 2. 按软件内引导连接微信
|
||||
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
|
||||
|
||||
首次启动会自动进入「第一次使用」页面。页面会根据当前系统显示连接方式和注意事项:
|
||||
## 3. 让应用读取微信数据
|
||||
|
||||
首次启动会自动进入“第一次使用”页面。页面会根据当前系统显示连接方式和注意事项:
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
|
||||
@@ -63,188 +56,96 @@ WechatExplorer / 迹忆应用安装包:[GitHub Releases](https://github.com/Wx
|
||||
|
||||
通常按下面三步操作即可:
|
||||
|
||||
1. **确认微信数据目录**:自动识别不准确时,在页面中修改存储路径。
|
||||
2. **让微信停在登录页面**:如果微信已经登录,先退出微信登录,不只是关闭窗口。
|
||||
3. **点击开始获取**:软件会尝试获取数据库密钥。按提示可以登录后,再回到微信完成登录。
|
||||
1. **确认微信数据目录**:自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径并在页面中修改。
|
||||
2. **让微信停在登录页面**:如果微信已经登录,先退出当前微信账号,不只是关闭微信窗口。
|
||||
3. **点击“开始连接”并按提示获取密钥**:软件准备好连接组件后会提示你登录微信;回到微信完成登录,再等待数据库、账号和联系人检查完成。
|
||||
|
||||
Windows 已完整支持,不需要关闭 SIP。macOS 首次获取密钥前,需要按页面提示完成授权并关闭 SIP。
|
||||
只有已经通过其他方式取得当前账号数据库密钥的高级用户,才需要选择“手动连接”。Windows 不需要关闭 SIP;macOS 是否需要额外授权或处理 SIP,以当前连接页面提示为准。
|
||||
|
||||
### 3. 连接成功
|
||||
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
|
||||
|
||||
连接成功后,软件会进入聊天档案,并显示「开始探索你的微信」引导:
|
||||
## 4. 确认第一次连接成功
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/first-use-welcome.png" alt="连接成功后的新手引导" width="760" />
|
||||
</p>
|
||||
连接成功后会进入“档案”页面。你可以用下面三个信号确认已经准备好:
|
||||
|
||||
这里推荐先体验「AI 群聊日报」,也可以直接查看聊天、问问微信或配置 AI 模型。
|
||||
- 左侧出现联系人或群聊列表;
|
||||
- 选中一个会话后,右侧能看到历史消息;
|
||||
- 搜索框可以在当前会话中定位文字。
|
||||
|
||||
## 连接成功后做什么
|
||||
如果联系人列表为空,先检查是否连到了正确账号和数据目录,再重新加载会话。
|
||||
|
||||
### AI 问问微信
|
||||
文字消息正常但图片打不开时,不代表数据库连接失败。打开“设置 → 图片解密”查看状态并尝试自动获取;图片原文件缺失、权限不足或密钥不匹配时,部分图片仍可能无法显示。
|
||||
|
||||
打开「问问微信」,用自然语言向自己的微信提问,例如:
|
||||
## 5. 完成你的第一个任务
|
||||
|
||||
- “技术群这周讨论了哪些问题?”
|
||||
- “帮我找到张三发过的项目地址。”
|
||||
- “去年我和老板聊过哪些关于涨薪的事情?”
|
||||
### 只是想找一句话
|
||||
|
||||
如果还没有配置 AI,点击「设置 → AI 模型」添加模型服务商并测试连接。
|
||||
进入“档案”,选择联系人或群聊,在会话内搜索关键词。适合你记得原话、姓名、链接或大致关键词的情况。
|
||||
|
||||
### AI 群聊日报
|
||||
### 想找一个模糊的结论
|
||||
|
||||
1. 打开「日报」。
|
||||
2. 选择一个群聊和时间范围。
|
||||
3. 按需要选择日报内容和模板。
|
||||
4. 开始生成,完成后查看或导出 HTML 与 PNG。
|
||||
先在“设置 → AI 模型”添加并测试一个 Provider,再进入“问问微信”描述问题,例如:
|
||||
|
||||
日报会整理讨论摘要、关键主题、重要消息、资源、问题和待跟进事项,并保留证据来源。
|
||||
- “上个月技术群讨论过哪些发布问题?”
|
||||
- “张三之前发过的项目地址在哪里?”
|
||||
- “过去一周有没有人提到退款?”
|
||||
|
||||
### 查看聊天
|
||||
这就是 AI Search:它会先帮你从本机聊天中找出相关内容,再让你配置的模型组织答案。你不需要知道关键词在哪,但问题越具体,结果越容易核对。
|
||||
|
||||
1. 打开「档案」。
|
||||
2. 选择好友或群聊。
|
||||
3. 浏览历史消息,也可以按关键词定位会话。
|
||||
### 想让 AI 的答案可核对
|
||||
|
||||
### 导出聊天
|
||||
回答生成后,打开来源或检索详情,查看它参考的聊天内容、会话、时间和原始消息。你可以从来源直接跳回“档案”检查上下文。
|
||||
|
||||
打开「导出」,选择联系人或群聊、时间范围和格式。支持 HTML、CSV、JSON 和 Markdown。
|
||||
产品把这些来源信息分别称为 Evidence、Citation 和 Search Trace;普通使用时只需要记住“答案可以回到原消息核对”即可。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||
|
||||
## 重新查看新手引导
|
||||
## 6. 接下来可以做什么
|
||||
|
||||
连接成功后,首次弹窗关闭不会影响功能使用。需要重新查看时,点击主界面左下角的「新手引导」:
|
||||
- [查看和搜索聊天](./chat-archive.md)
|
||||
- [使用 AI 查找聊天信息](./ai-search.md)
|
||||
- [建立本地知识库,让后续查找更稳定](./knowledge.md)
|
||||
- [生成群聊日报或总结](./report.md)
|
||||
- [转写微信语音](./voice.md)
|
||||
- [导出聊天档案](./export.md)
|
||||
- [可选开启防撤回](./recall-protection.md)
|
||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/guide-entry.png" alt="主界面左下角新手引导入口" width="760" />
|
||||
</p>
|
||||
## 7. 想直接在微信里提问
|
||||
|
||||
新手引导会再次展示:
|
||||
如果你希望直接在微信里向 TraceMemo 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub:
|
||||
|
||||
- AI 群聊日报入口。
|
||||
- 查看聊天记录入口。
|
||||
- 问问微信入口。
|
||||
- AI 模型配置入口。
|
||||
- 完整使用教程入口。
|
||||
1. 先完成上面的微信数据库连接,并确认“档案”里能看到聊天。
|
||||
2. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
|
||||
3. 确认 Agent Hub 显示“运行中”,数据 API/数据库状态可以查询。
|
||||
4. 点击“扫码登录微信机器人”,用微信扫描二维码,并在手机上确认登录。
|
||||
5. 状态变为“在线”后,向这个机器人发送文字消息。
|
||||
|
||||
## 配置 AI
|
||||
可以先试试这些真实支持的请求:
|
||||
|
||||
WechatExplorer 支持 OpenAI 兼容接口,也提供 DeepSeek、OpenAI、Claude、Moonshot 等常用配置方式。
|
||||
- “最近 5 个会话”;
|
||||
- “帮我看看最近跟张三聊了些什么”;
|
||||
- “生成产品交流群今天的群聊总结图片”。
|
||||
|
||||
1. 进入「设置 → AI 模型」。
|
||||
2. 添加模型服务商并填写 API Key。
|
||||
3. 确认 Base URL 和模型名称正确。
|
||||
4. 保存并测试连接。
|
||||
5. 返回「问问微信」或「日报」重试。
|
||||
机器人会把处理结果回复给发消息的人。联系人聊天总结、群聊总结和需要理解自然语言的请求依赖“设置 → AI 模型”中已经配置好的 AI 服务。当前实时入口主要处理文字消息;它不是支持任意图片、语音、文件理解、群发或定时任务的通用机器人。机器人账号扫码登录与读取你微信数据库是两条独立流程,都需要分别确认账号和权限。
|
||||
|
||||
AI 功能使用你配置的模型服务。相关聊天内容会按请求发送给该服务;是否启用以及使用哪一个服务由你决定。
|
||||
Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Skill 或配置 API Token。Reader Skill 和 API 只用于让外部 Agent 主动查询历史微信。
|
||||
|
||||
## 遇到问题
|
||||
## 8. 需要配置 AI 吗?
|
||||
|
||||
先判断你遇到的现象,再按对应路径处理。
|
||||
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
| --------------------------------- | -------------------------------------------------------- |
|
||||
| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 |
|
||||
| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 |
|
||||
| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 |
|
||||
| 数据库连接失败 | 当前账号是否匹配、微信版本是否兼容、数据库目录是否正确 |
|
||||
| 已连接但图片不显示 | 配置图片 XOR Key 和 AES Key |
|
||||
| AI 问问微信或日报不可用 | 在「设置 → AI 模型」配置并测试模型服务 |
|
||||
| API、Reader Skill 或 Agent 不可用 | 先连接数据库,再确认 API 服务状态和对应配置 |
|
||||
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
|
||||
|
||||
### 软件打不开
|
||||
## 9. 数据和隐私的最低须知
|
||||
|
||||
#### macOS
|
||||
- 微信数据库、聊天解析和本地索引默认留在本机。
|
||||
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
||||
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
||||
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
||||
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
|
||||
|
||||
- 出现“无法打开,因为开发者无法验证”:前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
- 出现“应用已损坏”:确认应用位于“应用程序”目录,再执行:
|
||||
完整边界见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
```
|
||||
## 10. 如果你卡住了
|
||||
|
||||
#### Windows
|
||||
|
||||
确认下载的是 Releases 中的 `-setup.exe` 安装包,并按安装向导完成安装。Windows 不需要关闭 SIP。
|
||||
|
||||
### 找不到微信数据
|
||||
|
||||
在首次连接页面确认“存储路径”。如果没有自动识别:
|
||||
|
||||
1. 打开「设置」。
|
||||
2. 手动选择微信数据所在目录。
|
||||
3. 返回连接页面,重新测试连接。
|
||||
|
||||
Windows 当前不会扫描二级目录,请确认目录没有多选或少选一层目录。
|
||||
|
||||
### 获取不到数据库密钥
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. 微信版本是否与上方已测试版本一致。
|
||||
2. 点击“开始获取”时,微信是否停留在未登录页面。
|
||||
3. 微信和 WechatExplorer 是否都保持运行。
|
||||
4. 微信数据目录是否准确。
|
||||
5. macOS 是否已关闭 SIP 并完成系统授权。
|
||||
|
||||
仍然失败时,可以在连接页面切换为“高级用户:已有数据库密钥?手动连接”,粘贴从其他兼容工具中取得的数据库密钥。手动输入的密钥必须与当前微信账号匹配。
|
||||
|
||||
### 数据库连接失败或账号不匹配
|
||||
|
||||
数据库密钥与微信账号绑定。请确认:
|
||||
|
||||
- 当前微信登录的是获取密钥时对应的账号。
|
||||
- WechatExplorer 选择的是该账号的数据目录。
|
||||
- 没有把其他账号或旧数据目录的密钥粘贴进来。
|
||||
|
||||
### 已连接但图片无法显示
|
||||
|
||||
微信 4.0 的图片通常以 `.dat` 文件存储。显示图片还需要:
|
||||
|
||||
- **XOR Key**:单字节十六进制值,例如 `0x40`。
|
||||
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
|
||||
|
||||
进入「设置 → 图片解密密钥」,选择自动获取或手动填写。也可以从 WeFlow 或 Chatlog 的设置中导出后填写。文字聊天记录不受图片密钥影响。
|
||||
|
||||
### 我已经连接成功,怎么重新查看教程?
|
||||
|
||||
点击左下角「新手引导」。
|
||||
|
||||
首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
|
||||
|
||||
## 接入 API、Reader Skill 或 Agent
|
||||
|
||||
这是高级使用路径,请先完成数据库连接并熟悉「问问微信、日报、档案、导出」的基础流程。
|
||||
|
||||
### Reader Skill
|
||||
|
||||
1. 打开应用的「API」页面。
|
||||
2. 确认本地 API 已运行;如果已停止,点击“启动服务”。
|
||||
3. 在“快速接入”中选择 Codex 或 Claude Code。
|
||||
4. 复制安装指令,粘贴给对应 Agent 执行。
|
||||
5. 安装完成后,让 Agent 读取和总结本地聊天。
|
||||
|
||||
本地 API 默认地址为 `http://127.0.0.1:6131`,默认仅监听本机且无鉴权。详细端点和参数见 [Reader Skill 文档](../skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### Agent Hub
|
||||
|
||||
应用内的「Agent」页面用于管理 WechatExplorer 的 Agent 连接与运行状态,属于高级功能。
|
||||
|
||||
## 数据与隐私
|
||||
|
||||
- WechatExplorer 只读取你有权访问的本机微信数据。
|
||||
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
|
||||
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
|
||||
- 本地 API 默认监听 `127.0.0.1`,且无鉴权。不要将它暴露在不可信的局域网环境中。
|
||||
|
||||
## 仍然无法解决?
|
||||
|
||||
请先完成上面的自助排查,再进入交流/售后群。提问时一次性提供:
|
||||
|
||||
1. 操作系统和版本。
|
||||
2. 微信版本。
|
||||
3. WechatExplorer 版本。
|
||||
4. 当前处于哪一步,以及完整错误信息。
|
||||
5. 必要截图;请遮挡账号、数据库密钥、API Key 和其他敏感信息。
|
||||
|
||||
交流二维码位于项目 [README](../../README.md) 文末。
|
||||
按现象进入[常见问题与排查](./troubleshooting.md):连接失败、聊天为空、AI 没有结果、语音模型不可用、导出失败和 Agent 无法访问分别有不同处理方式。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 把聊天变成更容易再次找到的本地资料
|
||||
|
||||
## 你为什么需要 Knowledge
|
||||
|
||||
如果你经常查同一批工作群、项目讨论或长期联系人,只靠每次临时翻聊天会越来越慢。Knowledge 会在本机建立一份可重复查找的索引,让“以前聊过什么”这类问题更容易跨会话、跨时间找到相关内容。
|
||||
|
||||
它不是另一个聊天窗口,也不会替你修改微信原始数据库;它是 TraceMemo 为当前账号维护的本地加速资料。
|
||||
|
||||
## 建立和同步
|
||||
|
||||
Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信”后,在“本地知识库”区域点击:
|
||||
|
||||
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
|
||||
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
|
||||
|
||||
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。
|
||||
|
||||
## 账号隔离
|
||||
|
||||
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
|
||||
|
||||
## 什么时候值得建立
|
||||
|
||||
- 你要跨多个群查过去几个月的内容;
|
||||
- 你反复查同一个项目、客户或主题;
|
||||
- 你希望 AI 先从更稳定的本地资料中找来源;
|
||||
- 你想减少每次搜索都重新读取大量原始记录的等待。
|
||||
|
||||
只偶尔查一条原话时,直接使用档案搜索通常更快。
|
||||
|
||||
## 清理和重建
|
||||
|
||||
在“设置 → 缓存与清理”中可以清理本地知识库索引、检索记录和导出任务缓存。清理索引不会删除微信原始聊天记录或数据库密钥;之后可以回到“问问微信”重新建立。
|
||||
|
||||
## 产品术语(可选)
|
||||
|
||||
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 数据、隐私与安全
|
||||
|
||||
TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有功能都完全离线。是否有数据离开电脑,取决于你是否启用了对应的 AI、Agent 或机器人能力。
|
||||
|
||||
## 默认留在本机的内容
|
||||
|
||||
以下处理由应用在本机完成:
|
||||
|
||||
- 读取和解析微信数据库;
|
||||
- 聊天档案浏览和普通关键词搜索;
|
||||
- 本地 Knowledge 索引及其账号隔离;
|
||||
- 离线语音转写;
|
||||
- 导出文件生成和本地日报历史。
|
||||
|
||||
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
||||
|
||||
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
|
||||
|
||||
## 什么时候会请求外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
||||
|
||||
- 当前用户问题;
|
||||
- 受控检索所需的有限上下文;
|
||||
- 最终用于总结的 Evidence。
|
||||
|
||||
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 TraceMemo 控制,请查看你所选服务商的政策。
|
||||
|
||||
Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的日志和配置仍由你负责。
|
||||
|
||||
## 语音和媒体
|
||||
|
||||
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
|
||||
|
||||
## Local HTTP API
|
||||
|
||||
- 默认监听地址为 `127.0.0.1:6131`,不是公网服务;
|
||||
- `/api/v1/health` 为公开健康检查;
|
||||
- 其他端点需要 `Authorization: Bearer <TOKEN>`;
|
||||
- 浏览器 CORS 只允许 HTTP 的 `localhost`、`127.0.0.1` 和 `[::1]` Origin;
|
||||
- 不带 Origin 的本地 CLI/Agent 请求可以使用 Token 访问;
|
||||
- API 不适合直接转发到公网或绑定到不受信任的网络接口。
|
||||
|
||||
Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `local-api-token.bin`,文件权限为仅当前用户可读写。你可以在“API Center”中显示、复制或重新生成 Token;重新生成会立即使旧 Token 失效。具体配置见[API 安全](../agent/api-security.md)。
|
||||
|
||||
## Agent 访问时发生什么
|
||||
|
||||
外部 Agent 通过 Reader Skill 调用本机 API,按需读取联系人、会话或时间范围内的聊天;它不会因此获得数据库文件路径或任意文件系统权限。Agent 是否把读取结果再次发送给模型,取决于 Agent 本身及其配置。
|
||||
|
||||
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 TraceMemo,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
|
||||
|
||||
机器人收到的文字会先进入本机 Agent Hub;如果任务需要总结或自然语言理解,受控上下文可能发送给你配置的 AI Provider。机器人账号扫码登录、个人微信数据库连接和外部 Agent/API Token 是不同的边界,使用前请分别确认账号与权限。
|
||||
|
||||
## 你可以主动做的事
|
||||
|
||||
- 不要把 API Token 放进 Git、截图、URL 或公开 Skill 文件;
|
||||
- 只连接你有权访问的微信数据;
|
||||
- 对需要外发的 AI 功能逐项确认 Provider;
|
||||
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
|
||||
- 在共享电脑上退出应用并保护系统账户。
|
||||
- 在开启防撤回前确认你接受其数据库写入、性能和清理边界,并先用微信官方方式备份重要数据。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 防撤回
|
||||
|
||||
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
||||
|
||||
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
||||
|
||||
## 如何开启
|
||||
|
||||
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
||||
2. 打开“设置 → 防撤回”。
|
||||
3. 阅读性能和数据提示后,开启“防撤回”。
|
||||
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
||||
|
||||
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
||||
|
||||
## 当前能做什么
|
||||
|
||||
- 监听应用能够识别到的后续撤回变化;
|
||||
- 在本地保留必要的消息和撤回关系;
|
||||
- 将已识别的原消息与撤回状态一起显示在档案中;
|
||||
- 按微信账号隔离 TraceMemo 保存的恢复记录。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
||||
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
||||
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
||||
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
||||
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
||||
|
||||
## 数据写入与关闭边界
|
||||
|
||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
||||
|
||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
||||
|
||||
完整的数据边界见[数据、隐私与安全](./privacy.md)。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 生成群聊日报和总结
|
||||
|
||||
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 TraceMemo 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
|
||||
|
||||
## 报告适合做什么
|
||||
|
||||
典型场景包括:
|
||||
|
||||
- 整理今天工作群的讨论重点;
|
||||
- 回顾昨天错过的决定和资源;
|
||||
- 汇总近 7 天的项目进展、待办和未解决问题;
|
||||
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
|
||||
|
||||
## 生成步骤
|
||||
|
||||
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
|
||||
|
||||
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
|
||||
2. 选择时间范围:今天、昨天或近 7 天。
|
||||
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
|
||||
4. 选择报告模板/内容模式并开始生成。
|
||||
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
|
||||
|
||||
报告可能包含主题、重要消息、问答、资源、待办、未解决事项、关键词、活跃统计,以及可用媒体的精选内容。具体展示内容会随消息类型、资源可用性和模型能力变化。
|
||||
|
||||
## 如何检查报告
|
||||
|
||||
报告中的重点结论会关联来源消息。对于重要决定、金额、时间和责任人,打开对应原消息核对,不要把 AI 生成的摘要当成新的事实来源。
|
||||
|
||||
图片无法读取时,报告可能只保留消息类型和上下文;模型未通过图片理解验证时,图片精选会被跳过。语音在日报中可参与数量和活跃度统计,但不要把统计当成语音内容已经被完整转写。
|
||||
|
||||
## 保存、查看和删除
|
||||
|
||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||
|
||||
## 让报告更可靠
|
||||
|
||||
- 先选正确的群和时间范围;
|
||||
- 不确定时先只选择文字消息;
|
||||
- 群太活跃时分成“今天”和“近 7 天”两次生成;
|
||||
- 看到待办和结论后回到原消息核对上下文;
|
||||
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
|
||||
@@ -0,0 +1,94 @@
|
||||
# 常见问题与排查
|
||||
|
||||
先按现象定位,不要为了“重置”而直接删除微信数据库或整个应用目录。
|
||||
|
||||
## 安装后软件无法打开
|
||||
|
||||
### Windows
|
||||
|
||||
1. 确认下载的是 GitHub Releases 中的 Windows x64 `-setup.exe`,并已完成安装。
|
||||
2. 安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
|
||||
3. 安装完成后重新启动 TraceMemo;如果仍无响应,再重新运行安装包进行覆盖安装。
|
||||
|
||||
### macOS
|
||||
|
||||
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
|
||||
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
|
||||
|
||||
完整安装步骤见[第一次使用 TraceMemo](./getting-started.md#2-安装并启动)。
|
||||
|
||||
## 连接微信失败
|
||||
|
||||
依次检查:
|
||||
|
||||
1. 数据目录是否指向当前登录账号,而不是旧备份或迁移前目录;
|
||||
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
|
||||
3. 微信是否处于页面要求的登录/退出状态;
|
||||
4. macOS 是否完成页面要求的授权;
|
||||
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。
|
||||
|
||||
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
|
||||
|
||||
## 连接成功但没有联系人或消息
|
||||
|
||||
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
|
||||
|
||||
## AI 没有结果或回答失败
|
||||
|
||||
- 先在“设置 → AI 模型”测试 Provider;
|
||||
- 检查问题的时间范围和会话范围是否过窄;
|
||||
- 确认 Knowledge 没有正在同步;
|
||||
- 打开检索详情,查看是本地查找为空、Provider 失败还是来源被过滤;
|
||||
- 把问题改成要求“只根据来源原文回答”。
|
||||
|
||||
AI Search 失败时可能仍保留部分来源;不要把部分结果当成完整覆盖。
|
||||
|
||||
## AI 答案看起来不对
|
||||
|
||||
打开来源和原始消息,检查发送者、时间和上下文。若来源不支持结论,扩大或缩小范围后重问。涉及未转写语音、缺失图片、转发和引用时,优先以原消息为准。
|
||||
|
||||
## Knowledge 一直在同步
|
||||
|
||||
首次建立或增量同步会在后台运行。查看“已索引消息、知识片段、磁盘占用”和同步详情;同步期间暂不能开始新的 AI 分析。若出现错误,旧索引可能仍可用,重启应用或在“缓存与清理”清理后重新建立。
|
||||
|
||||
## 语音转写失败
|
||||
|
||||
检查本地模型是否已准备、磁盘空间是否足够、单条语音是否仍有原始资源。批量任务可能部分成功;先处理失败项,不必重复转写已缓存内容。
|
||||
|
||||
## 媒体显示或导出异常
|
||||
|
||||
原图/缩略图目录缺失、权限不足或微信资源已被清理都会导致图片、视频或语音不可用。导出时可以切换缩略图、关闭媒体或保留缺失项,先确认文本档案是否正常。
|
||||
|
||||
文字正常但图片打不开时,进入“设置 → 图片解密”查看状态并尝试自动获取。密钥正确也不能恢复已经被微信清理的原图文件。
|
||||
|
||||
## 日报生成失败
|
||||
|
||||
日报只支持群聊。确认已选择群聊、时间范围内确实有消息、Provider 可用,并尝试先只选择文字消息。图片理解失败不会自动变成图片内容;报告可能跳过图片精选但仍生成文字日报。
|
||||
|
||||
## Agent 无法读取
|
||||
|
||||
确认:
|
||||
|
||||
1. TraceMemo 正在运行且 API Center 显示本地服务在线;
|
||||
2. Agent 使用的是当前 Reader Skill,而不是旧的 MCP 配置;
|
||||
3. 请求地址为 `http://127.0.0.1:6131`;
|
||||
4. 非 health 请求带有最新 `Authorization: Bearer <TOKEN>`;
|
||||
5. Token 重新生成后,Agent 配置已同步更新。
|
||||
|
||||
详细步骤见[Agent 接入概览](../agent/overview.md)和[API 安全](../agent/api-security.md)。
|
||||
|
||||
## 微信机器人无法连接或不回复
|
||||
|
||||
Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
|
||||
|
||||
1. “Agent”页面中的 Agent Hub、微信连接器和数据库状态是否正常;
|
||||
2. 二维码是否过期,手机是否已经确认登录;
|
||||
3. 是否由另一个微信账号向已登录的机器人账号发送文字;
|
||||
4. 请求是否属于当前支持的最近会话、联系人聊天、近 7 天联系人总结、群聊总结或群成员发言总结;
|
||||
5. 需要总结或自然语言理解时,AI Provider 是否可用。
|
||||
|
||||
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
|
||||
|
||||
## 防撤回没有保留消息
|
||||
|
||||
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 语音转文字
|
||||
|
||||
TraceMemo 可以把微信语音转换成可搜索的文字,适合你不想逐条播放、希望把语音内容带入后续查找或导出的场景。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
1. 打开“设置 → 语音识别”。
|
||||
2. 按页面提示准备或下载本地语音模型。
|
||||
3. 等待模型状态显示可用。
|
||||
|
||||
语音识别使用本地 SenseVoice/sherpa-onnx 运行时。首次准备模型可能需要下载文件和占用额外磁盘空间;模型文件可以从设置中删除,之后需要重新准备。
|
||||
|
||||
## 转写单条语音
|
||||
|
||||
在聊天档案中找到语音消息,点击转写入口。完成后,转写文本会与该消息关联,并可用于后续查看或检索。失败时查看消息提示和模型状态。
|
||||
|
||||
## 批量转写
|
||||
|
||||
在语音设置中选择联系人或群聊,再选择范围:
|
||||
|
||||
- 最近 30 天;
|
||||
- 当前年份;
|
||||
- 选择的历史范围。
|
||||
|
||||
开始前页面会显示语音条数、已缓存数量、待处理数量和预计耗时。批量任务支持进度、取消、缓存复用,并可能以“部分失败”结束;部分失败时可以根据列表重新处理未成功内容。
|
||||
|
||||
## 和 AI、知识库、导出的关系
|
||||
|
||||
- 本地转写结果可以参与本地知识库检索;
|
||||
- 导出时可选择是否包含已有语音转写;
|
||||
- AI Search 可能提示某些语音尚未转写,这意味着答案覆盖不完整;
|
||||
- 群聊日报默认会统计语音数量和时长,但不等于已经理解了每条语音的具体内容。
|
||||
|
||||
## 隐私提示
|
||||
|
||||
离线转写本身在本机完成。若你主动把转写结果用于 AI Search、日报或其他 AI 功能,受控文本可能按对应功能的规则发送给你配置的 Provider;详见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
+14
-9
@@ -1,5 +1,5 @@
|
||||
appId: com.wechatexplorer.app
|
||||
productName: WechatExplorer
|
||||
appId: com.tracememo.app
|
||||
productName: TraceMemo
|
||||
afterPack: scripts/after-pack.cjs
|
||||
directories:
|
||||
buildResources: build
|
||||
@@ -15,21 +15,26 @@ extraMetadata:
|
||||
main: out/main/index.js
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- node_modules/ffmpeg-static/**
|
||||
- node_modules/silk-wasm/**
|
||||
- node_modules/sherpa-onnx-node/**
|
||||
- node_modules/sherpa-onnx-*/**
|
||||
extraResources:
|
||||
# Includes the optional WeChat connector binary for the target platform.
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- from: docs/skill/wechatexplorer-reader
|
||||
to: skill/wechatexplorer-reader
|
||||
- '!connectors/wechat-personal/**'
|
||||
- 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
|
||||
@@ -42,10 +47,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.
|
||||
|
||||
@@ -7,12 +7,14 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts')
|
||||
index: resolve('src/main/index.ts'),
|
||||
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
|
||||
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js'
|
||||
},
|
||||
external: ['koffi']
|
||||
external: ['koffi', 'sherpa-onnx-node']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,6 +24,12 @@ export default defineConfig(
|
||||
'react-refresh': eslintPluginReactRefresh
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
endOfLine: 'auto'
|
||||
}
|
||||
],
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
...eslintPluginReactRefresh.configs.vite.rules
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+53
-13
@@ -1,19 +1,26 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.1.7",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"name": "tracememo",
|
||||
"version": "2.2.2",
|
||||
"packageManager": "pnpm@7.33.7",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"chat",
|
||||
"wechat chat",
|
||||
"wechat history",
|
||||
"mac微信",
|
||||
"windows微信",
|
||||
"微信聊天记录",
|
||||
"AI群聊总结助手"
|
||||
"微信聊天记录搜索",
|
||||
"微信AI",
|
||||
"微信机器人",
|
||||
"AI聊天搜索",
|
||||
"AI群聊总结",
|
||||
"本地AI"
|
||||
],
|
||||
"author": "Qingmao",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
|
||||
"url": "https://github.com/Wxw-Gu/TraceMemo.git"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
@@ -26,29 +33,33 @@
|
||||
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||
"cp:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:component": "vitest run --config vitest.component.config.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"benchmark:knowledge": "vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
|
||||
"benchmark:knowledge:capacity": "cross-env KNOWLEDGE_CAPACITY=1 vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
|
||||
"test:e2e:build": "electron-vite build",
|
||||
"test:knowledge-worker": "pnpm test:e2e:build && node scripts/test-knowledge-worker.cjs",
|
||||
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
|
||||
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
||||
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
|
||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch 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 && 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:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
|
||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"release": "npm run release:mac && npm run release:win",
|
||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --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",
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
@@ -57,15 +68,36 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-progress": "^1.1.16",
|
||||
"@radix-ui/react-radio-group": "^1.4.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-toast": "^1.2.23",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"archiver": "^8.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"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",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -78,12 +110,15 @@
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -92,13 +127,17 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jsdom": "^30.0.1",
|
||||
"postcss": "^8.5.26",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sass": "^1.102.0",
|
||||
"tailwindcss": "3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.10",
|
||||
"wrangler": "^4.28.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
@@ -113,7 +152,8 @@
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
"esbuild",
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2766
-32
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 238 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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+129
-1
@@ -1,15 +1,136 @@
|
||||
const { existsSync, renameSync } = require('node:fs')
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { chmodSync, existsSync, renameSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const asar = require('@electron/asar')
|
||||
|
||||
const COMPATIBILITY_NAME = 'Electron'
|
||||
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
|
||||
const REQUIRED_RUNTIME_PACKAGES = [
|
||||
'@electron-toolkit/preload',
|
||||
'@electron-toolkit/utils',
|
||||
'archiver',
|
||||
'electron-updater',
|
||||
'ffmpeg-static',
|
||||
'fs-extra',
|
||||
'jsonrepair',
|
||||
'koffi'
|
||||
]
|
||||
|
||||
function getRuntimeResources(context) {
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
return context.electronPlatformName === 'darwin'
|
||||
? path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources')
|
||||
: path.join(context.appOutDir, 'resources')
|
||||
}
|
||||
|
||||
function validateSilkWasmRuntime(runtimeResources) {
|
||||
const packagePath = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules', 'silk-wasm')
|
||||
const requiredFiles = [
|
||||
path.join(packagePath, 'package.json'),
|
||||
path.join(packagePath, 'lib', 'index.cjs'),
|
||||
path.join(packagePath, 'lib', 'silk.wasm')
|
||||
]
|
||||
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
|
||||
if (missingFiles.length > 0) {
|
||||
throw new Error(`Missing unpacked silk-wasm runtime: ${missingFiles.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateFfmpegRuntime(runtimeResources, platform = process.platform) {
|
||||
const executable = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const ffmpegPath = path.join(
|
||||
runtimeResources,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'ffmpeg-static',
|
||||
executable
|
||||
)
|
||||
if (!existsSync(ffmpegPath)) {
|
||||
throw new Error(`Missing unpacked ffmpeg-static runtime: ${ffmpegPath}`)
|
||||
}
|
||||
if (platform !== 'win32') chmodSync(ffmpegPath, 0o755)
|
||||
return ffmpegPath
|
||||
}
|
||||
|
||||
function validateSherpaRuntime(runtimeResources, platform, arch) {
|
||||
const platformName = platform === 'win32' ? 'win' : platform
|
||||
const basePath = path.join(
|
||||
runtimeResources,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'sherpa-onnx-node'
|
||||
)
|
||||
const nativePath = path.join(
|
||||
runtimeResources,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
`sherpa-onnx-${platformName}-${arch}`
|
||||
)
|
||||
const requiredFiles = [
|
||||
path.join(basePath, 'package.json'),
|
||||
path.join(basePath, 'sherpa-onnx.js'),
|
||||
path.join(nativePath, 'package.json'),
|
||||
path.join(nativePath, 'sherpa-onnx.node')
|
||||
]
|
||||
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
|
||||
if (missingFiles.length > 0) {
|
||||
throw new Error(`Missing unpacked sherpa-onnx runtime: ${missingFiles.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBuilderArch(arch) {
|
||||
if (typeof arch === 'string') return arch
|
||||
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
||||
}
|
||||
|
||||
function validateAsarRuntimeDependencies(runtimeResources) {
|
||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||
|
||||
// @electron/asar returns platform-native separators. Normalize to POSIX
|
||||
// paths so validation behaves consistently on Windows and macOS/Linux.
|
||||
const entries = new Set(asar.listPackage(asarPath).map((entry) => entry.replaceAll('\\', '/')))
|
||||
const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter(
|
||||
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
|
||||
)
|
||||
if (missingPackages.length > 0) {
|
||||
throw new Error(
|
||||
`Missing packaged runtime dependencies: ${missingPackages.join(', ')}. ` +
|
||||
'Use pnpm 7.33.7 so electron-builder can read pnpm-lock.yaml.'
|
||||
)
|
||||
}
|
||||
}
|
||||
function setPlistValue(plistPath, key, value) {
|
||||
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
|
||||
}
|
||||
|
||||
function validateReaderSkillRuntime(runtimeResources) {
|
||||
const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
|
||||
if (!existsSync(skillPath)) {
|
||||
throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`)
|
||||
}
|
||||
return skillPath
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const runtimeResources = getRuntimeResources(context)
|
||||
validateAsarRuntimeDependencies(runtimeResources)
|
||||
validateReaderSkillRuntime(runtimeResources)
|
||||
validateSilkWasmRuntime(runtimeResources)
|
||||
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
||||
validateSherpaRuntime(
|
||||
runtimeResources,
|
||||
context.electronPlatformName,
|
||||
normalizeBuilderArch(context.arch)
|
||||
)
|
||||
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
}
|
||||
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
const koffiNative = path.join(
|
||||
context.appOutDir,
|
||||
@@ -69,3 +190,10 @@ exports.default = async function afterPack(context) {
|
||||
setPlistValue(plistPath, 'CFBundleName', targetName)
|
||||
}
|
||||
}
|
||||
|
||||
exports.getRuntimeResources = getRuntimeResources
|
||||
exports.validateAsarRuntimeDependencies = validateAsarRuntimeDependencies
|
||||
exports.validateReaderSkillRuntime = validateReaderSkillRuntime
|
||||
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
||||
|
||||
@@ -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'))
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ANCHOR='com.tencent.wechat.update'
|
||||
ANCHOR_FILE='/etc/pf.anchors/wechat-update'
|
||||
PF_CONF='/etc/pf.conf'
|
||||
BACKUP_DIR='/etc/pf.conf.wechat-backups'
|
||||
CACHE_DIR="${HOME}/Library/Caches/com.tencent.xinWeChat"
|
||||
|
||||
domains='dldir1.qq.com dldir2.qq.com dldir3.qq.com dldir1v6.qq.com'
|
||||
|
||||
die() { printf '%s\n' "error: $*" >&2; exit 1; }
|
||||
need_root() { [ "$(id -u)" -eq 0 ] || die '请使用 sudo 执行此脚本'; }
|
||||
|
||||
resolve_ips() {
|
||||
command -v dig >/dev/null 2>&1 || die '需要 dig(macOS 通常已内置)'
|
||||
for domain in $domains; do
|
||||
dig +short A "$domain"
|
||||
dig +short AAAA "$domain"
|
||||
done | awk '/^[0-9]+(\.[0-9]+){3}$/ || /^[0-9A-Fa-f:]+:[0-9A-Fa-f:]+/' | sort -u
|
||||
}
|
||||
|
||||
enable() {
|
||||
need_root
|
||||
ips="$(resolve_ips)"
|
||||
[ -n "$ips" ] || die '域名解析没有返回 IP,未修改 PF'
|
||||
mkdir -p /etc/pf.anchors "$BACKUP_DIR"
|
||||
backup="$BACKUP_DIR/pf.conf.$(date +%Y%m%d-%H%M%S)"
|
||||
cp -p "$PF_CONF" "$backup"
|
||||
{
|
||||
printf 'table <wechat_update> persist { '
|
||||
printf '%s' "$ips" | tr '\n' ' '
|
||||
printf '}\nblock drop out quick to <wechat_update>\n'
|
||||
} > "$ANCHOR_FILE"
|
||||
if ! grep -Fq 'anchor "com.tencent.wechat.update"' "$PF_CONF"; then
|
||||
printf '\n# TraceMemo: block WeChat update endpoints\nanchor "com.tencent.wechat.update"\nload anchor "com.tencent.wechat.update" from "/etc/pf.anchors/wechat-update"\n' >> "$PF_CONF"
|
||||
fi
|
||||
pfctl -vnf "$PF_CONF"
|
||||
pfctl -f "$PF_CONF"
|
||||
pfctl -E >/dev/null 2>&1 || true
|
||||
printf '已启用微信更新拦截,规则备份:%s\n' "$backup"
|
||||
printf '当前 IP:\n%s\n' "$ips"
|
||||
}
|
||||
|
||||
disable() {
|
||||
need_root
|
||||
pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true
|
||||
printf '已清空微信更新 PF anchor。要完全移除配置行,请从 /etc/pf.conf 删除 TraceMemo 标记的三行。\n'
|
||||
}
|
||||
|
||||
status() {
|
||||
pfctl -s info 2>&1 | head -20
|
||||
printf '\n微信更新 anchor:\n'
|
||||
pfctl -a "$ANCHOR" -sr 2>&1 || true
|
||||
printf '\n缓存目录:\n%s\n' "$CACHE_DIR"
|
||||
ls -ldO "$CACHE_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
enable) enable ;;
|
||||
disable) disable ;;
|
||||
status) status ;;
|
||||
*) die "用法:sudo $0 {enable|disable|status}" ;;
|
||||
esac
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# TraceMemo v2.2.0 Local HTTP API 手动验收脚本
|
||||
# 仅用于 macOS Terminal;不会写入或输出真实 API Token。
|
||||
|
||||
set -u
|
||||
|
||||
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:6131}"
|
||||
API_BASE_URL="${API_BASE_URL%/}"
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tracememo-api-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
SKIP_COUNT=0
|
||||
|
||||
pass() { PASS_COUNT=$((PASS_COUNT + 1)); printf 'PASS %s\n' "$1"; }
|
||||
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); printf 'FAIL %s%s\n' "$1" "${2:+ ($2)}"; }
|
||||
skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); printf 'SKIP %s\n' "$1"; }
|
||||
|
||||
printf 'TraceMemo Local HTTP API 手动测试\n'
|
||||
printf 'API 地址: %s\n\n' "$API_BASE_URL"
|
||||
read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN
|
||||
printf '\n'
|
||||
if [[ -z "$API_TOKEN" ]]; then
|
||||
printf 'Token 不能为空。\n'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
request() {
|
||||
local method="$1" path="$2" auth="$3" origin="$4" body="${5:-}"
|
||||
local out="$TMP_DIR/body" headers="$TMP_DIR/headers" err="$TMP_DIR/error"
|
||||
local -a args=(--silent --show-error --max-time 10 -X "$method" -D "$headers" -o "$out" -w '%{http_code}')
|
||||
[[ "$auth" == 1 ]] && args+=(-H "Authorization: Bearer $API_TOKEN")
|
||||
[[ "$auth" == invalid ]] && args+=(-H 'Authorization: Bearer invalid')
|
||||
[[ "$auth" == malformed ]] && args+=(-H 'Authorization: abc')
|
||||
[[ "$auth" == bearer-only ]] && args+=(-H 'Authorization: Bearer')
|
||||
[[ -n "$origin" ]] && args+=(-H "Origin: $origin")
|
||||
if [[ -n "$body" ]]; then args+=(-H 'Content-Type: application/json' --data "$body"); fi
|
||||
: >"$out"
|
||||
: >"$headers"
|
||||
: >"$err"
|
||||
local status
|
||||
status="$(curl "${args[@]}" "$API_BASE_URL$path" 2>"$err")"
|
||||
CURL_STATUS="$status"
|
||||
CURL_BODY="$(<"$out")"
|
||||
CURL_HEADERS="$(<"$headers")"
|
||||
}
|
||||
|
||||
expect_status() {
|
||||
local name="$1" expected="$2" actual="$3"
|
||||
if [[ "$actual" == "$expected" ]]; then pass "$name ($actual)"; else fail "$name" "期望 ${expected},实际 ${actual:-000}"; fi
|
||||
}
|
||||
|
||||
printf '%s\n' '--- 基础鉴权 ---'
|
||||
request GET /api/v1/health 0 ''
|
||||
expect_status 'health 无 Token' 200 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time 0 ''
|
||||
expect_status '受保护 endpoint 无 Token' 401 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time invalid ''
|
||||
expect_status '错误 Token' 401 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time 1 ''
|
||||
expect_status '正确 Token' 200 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time malformed ''
|
||||
expect_status 'Authorization: abc' 401 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time bearer-only ''
|
||||
expect_status 'Authorization: Bearer' 401 "$CURL_STATUS"
|
||||
|
||||
printf '%s\n' '--- CORS ---'
|
||||
request OPTIONS /api/v1/health 0 http://localhost
|
||||
expect_status 'OPTIONS / CORS localhost' 204 "$CURL_STATUS"
|
||||
if [[ "$CURL_HEADERS" == *'Access-Control-Allow-Origin: http://localhost'* && "$CURL_HEADERS" == *'Access-Control-Allow-Headers: Content-Type, Authorization'* ]]; then
|
||||
pass 'localhost Origin 响应头'
|
||||
else
|
||||
fail 'localhost Origin 响应头'
|
||||
fi
|
||||
|
||||
request OPTIONS /api/v1/health 0 http://evil.example.com
|
||||
expect_status 'evil Origin 被拒绝' 403 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/health 0 ''
|
||||
if [[ "$CURL_STATUS" == 200 ]]; then pass '无 Origin 的 curl 请求'; else fail '无 Origin 的 curl 请求' "实际 ${CURL_STATUS:-000}"; fi
|
||||
|
||||
printf '%s\n' '--- API stop 后连接测试 ---'
|
||||
RUN_STOP_CHECK="${RUN_STOP_CHECK:-0}"
|
||||
if [[ -t 0 && "$RUN_STOP_CHECK" != 1 ]]; then
|
||||
read -r -p '现在请在 API Center 停止 API;完成后输入 y 验证连接失败,其他键跳过: ' STOP_CONFIRM
|
||||
[[ "$STOP_CONFIRM" == y || "$STOP_CONFIRM" == Y ]] && RUN_STOP_CHECK=1
|
||||
fi
|
||||
if [[ "$RUN_STOP_CHECK" == 1 ]]; then
|
||||
request GET /api/v1/health 0 ''
|
||||
if [[ "$CURL_STATUS" == 000 ]]; then
|
||||
pass 'API 已停止后连接失败'
|
||||
else
|
||||
fail 'API stop 后连接失败' "仍收到 HTTP ${CURL_STATUS:-000}"
|
||||
fi
|
||||
else
|
||||
skip '未执行 stop 验证;也可在停止 API 后使用 RUN_STOP_CHECK=1 重新运行'
|
||||
fi
|
||||
|
||||
printf '\n%s\n' '--- 人工验证项目(脚本不会自动操作) ---'
|
||||
printf '%s\n' '1. API Center 默认隐藏 Token,点击“显示 Token”后可见,再点击隐藏。'
|
||||
printf '%s\n' '2. 点击“复制 Token”,粘贴到安全位置确认复制成功;终端不要回显 Token。'
|
||||
printf '%s\n' '3. 点击“重新生成 Token”并确认二次确认提示。'
|
||||
printf '%s\n' '4. rotation 后,用旧 Token 请求 /api/v1/current_time 应立即返回 401。'
|
||||
printf '%s\n' '5. 重启 App 后 Token 应保持不变。'
|
||||
printf '%s\n' '6. 将 apiEnabled=false 后,API 应不再监听(可重新运行本脚本的 stop 测试)。'
|
||||
|
||||
printf '\n结果:PASS=%d FAIL=%d SKIP=%d\n' "$PASS_COUNT" "$FAIL_COUNT" "$SKIP_COUNT"
|
||||
if (( FAIL_COUNT > 0 )); then exit 1; fi
|
||||
exit 0
|
||||
@@ -1,12 +1,8 @@
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const runtimeNames = [
|
||||
'msvcp140.dll',
|
||||
'msvcp140_1.dll',
|
||||
'vcruntime140.dll',
|
||||
'vcruntime140_1.dll'
|
||||
]
|
||||
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
|
||||
|
||||
function copyIfDifferent(sourcePath, targetPath) {
|
||||
const source = fs.statSync(sourcePath)
|
||||
@@ -23,7 +19,49 @@ function copyIfDifferent(sourcePath, targetPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
function readOption(name, fallback) {
|
||||
const index = process.argv.indexOf(`--${name}`)
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
|
||||
}
|
||||
|
||||
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
|
||||
let packageRoot = ''
|
||||
try {
|
||||
packageRoot = path.dirname(require.resolve('ffmpeg-static/package.json'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const ffmpegPath = path.join(packageRoot, executable)
|
||||
|
||||
if (!fs.existsSync(ffmpegPath)) {
|
||||
const installScript = path.join(packageRoot, 'install.js')
|
||||
console.log(
|
||||
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
|
||||
)
|
||||
execFileSync(process.execPath, [installScript], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_platform: targetPlatform,
|
||||
npm_config_arch: targetArch
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!fs.existsSync(ffmpegPath)) {
|
||||
throw new Error(`ffmpeg-static runtime download failed: ${ffmpegPath}`)
|
||||
}
|
||||
if (targetPlatform === 'win32') return
|
||||
|
||||
fs.chmodSync(ffmpegPath, 0o755)
|
||||
if (process.platform === 'darwin') {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { stdio: 'ignore' })
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
prepareFfmpegRuntime(readOption('platform', process.platform), readOption('arch', process.arch))
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* wechat_chatter runtime integration
|
||||
*
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* Upstream license: GNU General Public License version 3 (GPL-3.0)
|
||||
*
|
||||
* This file applies local compatibility patches to the upstream
|
||||
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const release = 'v0.0.18'
|
||||
const asset = 'onebot_mac_arm64.tar.gz'
|
||||
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const outputDir = path.join(
|
||||
projectRoot,
|
||||
'resources',
|
||||
'connectors',
|
||||
'wechat-personal',
|
||||
'darwin-arm64'
|
||||
)
|
||||
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
|
||||
const appleSilicon =
|
||||
process.platform === 'darwin' &&
|
||||
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
|
||||
|
||||
if (!appleSilicon) {
|
||||
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
let archiveReady = false
|
||||
if (fs.existsSync(archive)) {
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
|
||||
archiveReady = true
|
||||
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
|
||||
} catch {
|
||||
// The archive is partial or invalid; curl will resume it below.
|
||||
}
|
||||
}
|
||||
if (!archiveReady) {
|
||||
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
|
||||
execFileSync(
|
||||
'/usr/bin/curl',
|
||||
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
|
||||
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
|
||||
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
|
||||
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
|
||||
fs.copyFileSync(
|
||||
path.join(extractionDir, 'onebot', 'script.js'),
|
||||
path.join(outputDir, 'onebot', 'script.js')
|
||||
)
|
||||
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
|
||||
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
|
||||
fs.chmodSync(stagedExecutable, 0o755)
|
||||
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
|
||||
} finally {
|
||||
fs.rmSync(extractionDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const executable = path.join(outputDir, 'onebot', 'onebot')
|
||||
const script = path.join(outputDir, 'onebot', 'script.js')
|
||||
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
|
||||
for (const required of [executable, script, config]) {
|
||||
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
|
||||
}
|
||||
|
||||
function patchPerSendPayload(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||
|
||||
const declarations = 'var triggerX1Payload;\nvar triggerX0;'
|
||||
const patchedDeclarations =
|
||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
|
||||
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
triggerX1Payload.writeByteArray(payloadData);
|
||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||
|
||||
if (!source.includes(declarations) || !source.includes(originalSend)) {
|
||||
throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||
}
|
||||
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
|
||||
source = source.replace(
|
||||
' MMStartTask(triggerX0, triggerX1Payload);',
|
||||
' MMStartTask(triggerX0, activeTriggerX1Payload);'
|
||||
)
|
||||
source = source.replace(
|
||||
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||
)
|
||||
source = source.replace(
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
|
||||
}
|
||||
|
||||
function patchImageHookReadiness(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (
|
||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||
source.includes('图片上传 Hook Setup Complete')
|
||||
)
|
||||
return
|
||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||
\t\t\t}
|
||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
|
||||
source = source.replace(original, patched)
|
||||
source = source.replace(
|
||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
||||
}
|
||||
|
||||
function patchWechatCoreModuleBase(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('WeChat core module base:')) return
|
||||
const initMarker = 'function initAddresses() {'
|
||||
const initIndex = source.indexOf(initMarker)
|
||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
|
||||
}
|
||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||
var module = Process.enumerateModules().find(function(m) {
|
||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||
});
|
||||
if (!module) {
|
||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||
}
|
||||
var moduleBase = module.base;
|
||||
var baseAddr = moduleBase;
|
||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||
setImmediate(initAddresses);
|
||||
|
||||
`
|
||||
source = patchedHeader + source.slice(initIndex)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
|
||||
}
|
||||
|
||||
function addModifiedWorkNotice(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||
|
||||
const notice = `/*
|
||||
* TraceMemo wechat_chatter compatibility modifications
|
||||
* Modified: 2026-08-17
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* License: GNU General Public License version 3 (GPL-3.0)
|
||||
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
|
||||
* These modifications are not provided by the upstream author.
|
||||
*/
|
||||
|
||||
`
|
||||
source = notice + source
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已写入 GPL 修改声明')
|
||||
}
|
||||
|
||||
patchWechatCoreModuleBase(script)
|
||||
patchPerSendPayload(script)
|
||||
patchImageHookReadiness(script)
|
||||
addModifiedWorkNotice(script)
|
||||
fs.chmodSync(executable, 0o755)
|
||||
console.log('[wechat-personal] 运行时准备完成')
|
||||
@@ -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: '',
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
const { fork } = require('node:child_process')
|
||||
const { existsSync, mkdtempSync } = require('node:fs')
|
||||
const { rm } = require('node:fs/promises')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join } = require('node:path')
|
||||
const { randomUUID, createHash } = require('node:crypto')
|
||||
|
||||
const workerPath = join(__dirname, '..', 'out', 'main', 'knowledgeWorker.js')
|
||||
if (!existsSync(workerPath)) throw new Error(`Knowledge worker build is missing: ${workerPath}`)
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-worker-'))
|
||||
const child = fork(workerPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
serialization: 'advanced',
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
const pending = new Map()
|
||||
|
||||
function request(type, payload) {
|
||||
const requestId = randomUUID()
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(requestId, { resolve, reject })
|
||||
child.send({ version: 1, type, requestId, payload }, (error) => {
|
||||
if (error) reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
child.on('message', (message) => {
|
||||
if (!message || message.type === 'progress') return
|
||||
const current = pending.get(message.requestId)
|
||||
if (!current) return
|
||||
pending.delete(message.requestId)
|
||||
if (message.type === 'error') current.reject(new Error(message.error))
|
||||
else current.resolve(message.payload)
|
||||
})
|
||||
|
||||
function fts(profileId) {
|
||||
return {
|
||||
profileId,
|
||||
tokenizer: 'trigram',
|
||||
contentMode: 'external',
|
||||
detail: 'full',
|
||||
columnsize: 1
|
||||
}
|
||||
}
|
||||
|
||||
function conversation(accountId, id) {
|
||||
return {
|
||||
conversationId: `conversation-${id}`,
|
||||
completeSnapshot: true,
|
||||
messages: [
|
||||
{
|
||||
accountId,
|
||||
conversationId: `conversation-${id}`,
|
||||
messageId: `message-${id}`,
|
||||
createTime: 1,
|
||||
senderId: 'fixture-member',
|
||||
senderName: '脱敏成员',
|
||||
kind: 'text',
|
||||
text: `脱敏索引内容 ${id}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function accountPath(accountId) {
|
||||
const key = createHash('sha256')
|
||||
.update(`knowledge-account-v1:${accountId}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
return join(root, key, 'knowledge.sqlite')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const chunker = {
|
||||
version: 'conversation-v1',
|
||||
maxGapMs: 600000,
|
||||
maxMessages: 12,
|
||||
maxCharacters: 1200,
|
||||
overlapMessages: 3
|
||||
}
|
||||
const accountA = 'worker-fixture-a'
|
||||
const accountB = 'worker-fixture-b'
|
||||
const first = await request('index', {
|
||||
accountId: accountA,
|
||||
databaseRoot: root,
|
||||
conversations: [conversation(accountA, 'a')],
|
||||
chunker,
|
||||
fts: fts('worker-a')
|
||||
})
|
||||
await request('index', {
|
||||
accountId: accountB,
|
||||
databaseRoot: root,
|
||||
conversations: [conversation(accountB, 'b')],
|
||||
chunker,
|
||||
fts: fts('worker-b')
|
||||
})
|
||||
if (
|
||||
!first ||
|
||||
first.cancelled ||
|
||||
!existsSync(accountPath(accountA)) ||
|
||||
!existsSync(accountPath(accountB))
|
||||
) {
|
||||
throw new Error('Knowledge worker did not create isolated derived databases')
|
||||
}
|
||||
const search = await request('search', {
|
||||
accountId: accountA,
|
||||
databaseRoot: root,
|
||||
fts: fts('worker-a'),
|
||||
text: '查询脱敏索引内容 a',
|
||||
terms: ['脱敏索引内容', 'a'],
|
||||
limit: 10
|
||||
})
|
||||
const evidence = search?.evidence?.[0]
|
||||
if (
|
||||
search?.state !== 'ready' ||
|
||||
!evidence ||
|
||||
evidence.messageId !== 'message-a' ||
|
||||
evidence.conversationId !== 'conversation-a' ||
|
||||
evidence.sender !== '脱敏成员' ||
|
||||
typeof evidence.timestamp !== 'number'
|
||||
) {
|
||||
throw new Error('Knowledge worker search did not return message-level evidence')
|
||||
}
|
||||
await request('remove', { accountId: accountA, databaseRoot: root })
|
||||
if (existsSync(accountPath(accountA)) || !existsSync(accountPath(accountB))) {
|
||||
throw new Error('Knowledge worker removal crossed an account boundary')
|
||||
}
|
||||
const unavailable = await request('search', {
|
||||
accountId: accountA,
|
||||
databaseRoot: root,
|
||||
fts: fts('worker-a'),
|
||||
text: '查询脱敏索引内容 a',
|
||||
terms: ['脱敏索引内容'],
|
||||
limit: 10
|
||||
})
|
||||
if (unavailable?.state !== 'unavailable' || unavailable.evidence?.length) {
|
||||
throw new Error('Knowledge worker did not report unavailable index after removal')
|
||||
}
|
||||
await request('close', {})
|
||||
console.log('Knowledge worker integration check passed')
|
||||
} finally {
|
||||
child.kill()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -15,30 +15,55 @@ const filePath = path.join(
|
||||
'buildSkillInstallInstruction.ts'
|
||||
)
|
||||
const source = fs.readFileSync(filePath, 'utf8')
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const moduleExports = {}
|
||||
new Function('exports', 'require', 'module', output)(moduleExports, require, { exports: moduleExports })
|
||||
new Function('exports', 'require', 'module', output)(moduleExports, require, {
|
||||
exports: moduleExports
|
||||
})
|
||||
|
||||
const { buildSkillInstallInstruction } = moduleExports
|
||||
const local = { type: 'local', directoryPath: 'C:/skill/wechatexplorer-reader', skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', version: 'v1.0' }
|
||||
const local = {
|
||||
type: 'local',
|
||||
directoryPath: 'C:/skill/tracememo-reader',
|
||||
skillPath: 'C:/skill/tracememo-reader/SKILL.md',
|
||||
version: 'v1.0'
|
||||
}
|
||||
|
||||
for (const [target, expected] of [
|
||||
['codex', 'Codex 项目或用户 Skill 目录'],
|
||||
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
|
||||
['openclaw', '作为 WechatExplorer Reader Skill 安装'],
|
||||
['openclaw', '作为 TraceMemo Reader Skill 安装'],
|
||||
['generic', '读取并安装']
|
||||
]) {
|
||||
const text = buildSkillInstallInstruction({ target, source: local, apiBaseUrl: { host: '127.0.0.1', port: 6131 } })
|
||||
const text = buildSkillInstallInstruction({
|
||||
target,
|
||||
source: local,
|
||||
apiBaseUrl: { host: '127.0.0.1', port: 6131 }
|
||||
})
|
||||
assert.match(text, new RegExp(expected))
|
||||
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
|
||||
assert.match(text, /TRACEMEMO_API_TOKEN/)
|
||||
assert.match(text, /WECHATEXPLORER_API_TOKEN/)
|
||||
assert.match(text, /Authorization: Bearer/)
|
||||
assert.doesNotMatch(text, /mcpServers/)
|
||||
}
|
||||
|
||||
assert.match(
|
||||
buildSkillInstallInstruction({ target: 'codex', source: local, apiBaseUrl: { host: '0.0.0.0', port: 7000 } }),
|
||||
buildSkillInstallInstruction({
|
||||
target: 'codex',
|
||||
source: local,
|
||||
apiBaseUrl: { host: '0.0.0.0', port: 7000 }
|
||||
}),
|
||||
/http:\/\/127\.0\.0\.1:7000\/api\/v1\/health/
|
||||
)
|
||||
assert.match(
|
||||
buildSkillInstallInstruction({ target: 'generic', source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' }, apiBaseUrl: { host: 'localhost', port: 6131 } }),
|
||||
buildSkillInstallInstruction({
|
||||
target: 'generic',
|
||||
source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' },
|
||||
apiBaseUrl: { host: 'localhost', port: 6131 }
|
||||
}),
|
||||
/https:\/\/example\.com\/skill/
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import crypto from 'crypto'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
ApiTokenActionResult,
|
||||
ApiTokenRevealResult,
|
||||
ApiTokenStatus
|
||||
} from '../shared/local-api-auth'
|
||||
|
||||
const MASKED_TOKEN = '••••••••••••••••'
|
||||
const TOKEN_BYTES = 32
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
interface TokenReadResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class ApiTokenStore {
|
||||
private cachedToken: string | null = null
|
||||
private automaticGenerationBlockedReason: string | null = null
|
||||
|
||||
constructor(private readonly filePathOverride?: string) {}
|
||||
|
||||
private get filePath(): string {
|
||||
return this.filePathOverride || path.join(app.getPath('userData'), 'local-api-token.bin')
|
||||
}
|
||||
|
||||
getStatus(): ApiTokenStatus {
|
||||
const available = safeStorage.isEncryptionAvailable()
|
||||
if (!available) {
|
||||
return {
|
||||
available: false,
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
|
||||
}
|
||||
}
|
||||
const result = this.read()
|
||||
return {
|
||||
available: true,
|
||||
hasToken: result.success && Boolean(result.token),
|
||||
maskedToken: MASKED_TOKEN,
|
||||
...(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 {
|
||||
success: false,
|
||||
available: false,
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
revealToken(): ApiTokenRevealResult {
|
||||
const ensured = this.ensureToken()
|
||||
if (!ensured.success) return ensured
|
||||
return { ...ensured, token: this.cachedToken || undefined }
|
||||
}
|
||||
|
||||
rotateToken(): ApiTokenActionResult {
|
||||
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
|
||||
const result = this.persist(this.generateToken())
|
||||
if (result.success) this.automaticGenerationBlockedReason = null
|
||||
return result
|
||||
}
|
||||
|
||||
getTokenForAuthentication(): string | null {
|
||||
if (this.cachedToken) return this.cachedToken
|
||||
const result = this.read()
|
||||
return result.success ? result.token || null : null
|
||||
}
|
||||
|
||||
private generateToken(): string {
|
||||
return crypto.randomBytes(TOKEN_BYTES).toString('base64url')
|
||||
}
|
||||
|
||||
private read(): TokenReadResult {
|
||||
if (this.cachedToken) return { success: true, token: this.cachedToken }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
if (!fs.existsSync(this.filePath)) return { success: true }
|
||||
try {
|
||||
const token = safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
if (!TOKEN_PATTERN.test(token)) throw new Error('invalid token data')
|
||||
this.cachedToken = token
|
||||
return { success: true, token }
|
||||
} catch {
|
||||
return { success: false, error: '已保存的 API Token 无法从系统安全存储读取' }
|
||||
}
|
||||
}
|
||||
|
||||
private persist(token: string): ApiTokenActionResult {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(token), { mode: 0o600 })
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
this.cachedToken = token
|
||||
return this.actionSuccess()
|
||||
} catch {
|
||||
return this.actionError('API Token 无法保存到系统安全存储')
|
||||
}
|
||||
}
|
||||
|
||||
private actionSuccess(): ApiTokenActionResult {
|
||||
return {
|
||||
success: true,
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: MASKED_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
private actionError(error?: string): ApiTokenActionResult {
|
||||
return {
|
||||
success: false,
|
||||
available: safeStorage.isEncryptionAvailable(),
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: error || 'API Token 安全存储不可用'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiTokenStore = new ApiTokenStore()
|
||||
@@ -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 {
|
||||
|
||||
+1817
-35
File diff suppressed because it is too large
Load Diff
+1455
-83
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
export type FileAssetResult = {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
fileName?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const monthName = (timestamp?: number): string => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export class FileAssetService {
|
||||
private index:
|
||||
| {
|
||||
root: string
|
||||
files: { filePath: string; fileName: string; month: string; mtimeMs: number }[]
|
||||
}
|
||||
| undefined
|
||||
|
||||
constructor(private readonly client: Pick<Wcdb4Client, 'getAccountRoot'>) {}
|
||||
|
||||
resolve(fileTitle: string, createTime?: number): FileAssetResult {
|
||||
const normalizedTitle = path.basename(
|
||||
String(fileTitle || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
)
|
||||
if (!normalizedTitle) return { success: false, error: '文件名为空,无法定位本地附件' }
|
||||
|
||||
const configuredRoot = path.resolve(this.client.getAccountRoot(), 'msg', 'file')
|
||||
if (!fs.existsSync(configuredRoot)) {
|
||||
return { success: false, error: '本地文件附件目录不存在' }
|
||||
}
|
||||
const root = fs.realpathSync(configuredRoot)
|
||||
|
||||
const preferredMonth = monthName(createTime)
|
||||
const extension = path.extname(normalizedTitle)
|
||||
const stem = normalizedTitle.slice(0, normalizedTitle.length - extension.length)
|
||||
const duplicatePattern = new RegExp(
|
||||
`^${escapeRegExp(stem)}(?:\\(\\d+\\))?${escapeRegExp(extension)}$`,
|
||||
'i'
|
||||
)
|
||||
const expectedTime = createTime ? createTime * 1000 : 0
|
||||
const candidates = this.getIndex(root).filter(({ fileName }) => duplicatePattern.test(fileName))
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
const leftPreferred = left.month === preferredMonth ? 1 : 0
|
||||
const rightPreferred = right.month === preferredMonth ? 1 : 0
|
||||
if (leftPreferred !== rightPreferred) return rightPreferred - leftPreferred
|
||||
const leftExact = left.fileName === normalizedTitle ? 1 : 0
|
||||
const rightExact = right.fileName === normalizedTitle ? 1 : 0
|
||||
if (leftExact !== rightExact) return rightExact - leftExact
|
||||
if (expectedTime) {
|
||||
const timeDifference =
|
||||
Math.abs(left.mtimeMs - expectedTime) - Math.abs(right.mtimeMs - expectedTime)
|
||||
if (timeDifference) return timeDifference
|
||||
}
|
||||
return left.fileName.localeCompare(right.fileName)
|
||||
})
|
||||
|
||||
const selected = candidates[0]
|
||||
if (!selected) return { success: false, error: `本地未找到文件附件:${normalizedTitle}` }
|
||||
return { success: true, filePath: selected.filePath, fileName: selected.fileName }
|
||||
}
|
||||
|
||||
private isSafeChild(root: string, candidate: string): boolean {
|
||||
return candidate === root || candidate.startsWith(`${root}${path.sep}`)
|
||||
}
|
||||
|
||||
private getIndex(
|
||||
root: string
|
||||
): { filePath: string; fileName: string; month: string; mtimeMs: number }[] {
|
||||
if (this.index?.root === root) return this.index.files
|
||||
const files: { filePath: string; fileName: string; month: string; mtimeMs: number }[] = []
|
||||
for (const month of fs.readdirSync(root)) {
|
||||
const monthPath = path.resolve(root, month)
|
||||
if (!this.isSafeChild(root, monthPath) || !this.isDirectory(monthPath)) continue
|
||||
for (const fileName of fs.readdirSync(monthPath)) {
|
||||
const candidate = path.resolve(monthPath, fileName)
|
||||
if (!this.isSafeChild(root, candidate) || !this.isFile(candidate)) continue
|
||||
const filePath = fs.realpathSync(candidate)
|
||||
if (!this.isSafeChild(root, filePath)) continue
|
||||
files.push({ filePath, fileName, month, mtimeMs: this.mtimeMs(filePath) })
|
||||
}
|
||||
}
|
||||
this.index = { root, files }
|
||||
return files
|
||||
}
|
||||
|
||||
private isDirectory(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isFile(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private mtimeMs(filePath: string): number {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+78
-17
@@ -1,3 +1,4 @@
|
||||
import crypto from 'crypto'
|
||||
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
@@ -12,6 +13,7 @@ import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
@@ -29,6 +31,10 @@ interface RouteContext {
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
@@ -36,12 +42,50 @@ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store'
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function isAllowedCorsOrigin(origin: string): boolean {
|
||||
if (!/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(origin)) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
if (parsed.protocol !== 'http:') return false
|
||||
if (parsed.username || parsed.password) return false
|
||||
return ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
const origin = req.headers.origin
|
||||
if (!origin) return true
|
||||
if (!isAllowedCorsOrigin(origin)) return false
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
return true
|
||||
}
|
||||
|
||||
function isAuthorized(req: IncomingMessage, expectedToken: string | null): boolean {
|
||||
const header = req.headers.authorization
|
||||
const match = typeof header === 'string' ? /^Bearer ([A-Za-z0-9_-]+)$/.exec(header) : null
|
||||
if (!match || !expectedToken) return false
|
||||
const actualDigest = crypto.createHash('sha256').update(match[1], 'utf8').digest()
|
||||
const expectedDigest = crypto.createHash('sha256').update(expectedToken, 'utf8').digest()
|
||||
return crypto.timingSafeEqual(actualDigest, expectedDigest)
|
||||
}
|
||||
|
||||
function sendUnauthorized(res: ServerResponse): void {
|
||||
sendJson(res, 401, {
|
||||
error: 'unauthorized',
|
||||
message: 'Valid API token required'
|
||||
})
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
@@ -123,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()
|
||||
})
|
||||
@@ -142,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)
|
||||
@@ -153,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) {
|
||||
@@ -168,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')
|
||||
|
||||
@@ -213,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)
|
||||
@@ -222,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)
|
||||
@@ -232,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')
|
||||
}
|
||||
@@ -256,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 : '{}')
|
||||
@@ -301,24 +345,28 @@ const routes: Record<string, RouteHandler> = {
|
||||
|
||||
export function startHttpServer(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT
|
||||
port: number = DEFAULT_HTTP_PORT,
|
||||
options: HttpServerOptions = {}
|
||||
): Promise<HttpServerHandle> {
|
||||
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
if (!applyCorsHeaders(req, res)) {
|
||||
return sendError(res, 403, 'Origin 不允许访问本地 API')
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*'
|
||||
})
|
||||
res.writeHead(204)
|
||||
return res.end()
|
||||
}
|
||||
const handler = routes[url.pathname]
|
||||
if (!handler) {
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
|
||||
return sendUnauthorized(res)
|
||||
}
|
||||
let body: string | undefined
|
||||
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
body = await readBody(req)
|
||||
@@ -391,11 +439,24 @@ export const apiServer = {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
const token = apiTokenStore.ensureToken()
|
||||
if (!token.success) {
|
||||
singletonState = {
|
||||
running: false,
|
||||
host,
|
||||
port,
|
||||
error: token.error || 'API Token 安全存储不可用'
|
||||
}
|
||||
return { ...singletonState }
|
||||
}
|
||||
|
||||
const maxAttempts = 4
|
||||
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
singleton = await startHttpServer(host, port)
|
||||
singleton = await startHttpServer(host, port, {
|
||||
tokenProvider: () => apiTokenStore.getTokenForAuthentication()
|
||||
})
|
||||
singletonState = {
|
||||
running: true,
|
||||
host: singleton.host,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user