Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6db4de711 | ||
|
|
adff6021fc | ||
|
|
75004375df | ||
|
|
75de616527 | ||
|
|
f5158fd3e4 | ||
|
|
ed4c21a68f | ||
|
|
f35ca1778a | ||
|
|
21b2b57d28 | ||
|
|
67dc45819e | ||
|
|
ff212d594c | ||
|
|
353df30ff8 | ||
|
|
4f8435d0e2 | ||
|
|
389a853560 | ||
|
|
c437be349e | ||
|
|
488966d771 | ||
|
|
00095ae627 | ||
|
|
ffbe78f74a | ||
|
|
7e9a79486e | ||
|
|
4191d4d5f5 | ||
|
|
29c1166eca | ||
|
|
bf78055b69 | ||
|
|
550e3b104c | ||
|
|
cf9ea8a83b | ||
|
|
5efae86fcf | ||
|
|
70c55442f3 | ||
|
|
9288256a5e | ||
|
|
e5bd162a01 | ||
|
|
cef1ad493c | ||
|
|
add16885fe | ||
|
|
ab3b3731b7 | ||
|
|
0c4932d740 | ||
|
|
fe8619d56d | ||
|
|
376ea79ff6 | ||
|
|
dc6d1eb6ac | ||
|
|
532e135406 | ||
|
|
7ae0d63a7f | ||
|
|
c759bcd30e | ||
|
|
0bad419150 | ||
|
|
facc292443 | ||
|
|
7554de7d72 | ||
|
|
1bc66e33fd | ||
|
|
976aac32c5 | ||
|
|
3634f49a48 | ||
|
|
1156362c3c | ||
|
|
60528ffe53 | ||
|
|
f28480ffbe | ||
|
|
8e784d8125 | ||
|
|
3489535750 | ||
|
|
5e268cd8f4 | ||
|
|
454f449c41 | ||
|
|
517e650942 | ||
|
|
21e299942b | ||
|
|
bde3bc537f | ||
|
|
719ffa0a24 | ||
|
|
8fa4dd50e7 | ||
|
|
3462d49516 | ||
|
|
7ef991aa67 | ||
|
|
4d758b2aca | ||
|
|
27f4108cf9 | ||
|
|
4075e52eec | ||
|
|
f7d619292b | ||
|
|
e1f4ec79dd | ||
|
|
76e9e683c4 | ||
|
|
7c8b45a24a | ||
|
|
1c8bbae1f0 | ||
|
|
25b72c3d76 | ||
|
|
f65dc4ac32 | ||
|
|
e59332b514 | ||
|
|
6cbf9af662 | ||
|
|
5acfa14475 | ||
|
|
698251796f | ||
|
|
a007e110ac | ||
|
|
7cf043e3c3 | ||
|
|
949e6bc868 | ||
|
|
62abd872ee | ||
|
|
0d68217dda | ||
|
|
ee5f50db83 | ||
|
|
36e709d7a6 | ||
|
|
b26d4dd1a7 | ||
|
|
8f9a0c44da | ||
|
|
7016ca1237 | ||
|
|
fc42b18f4a | ||
|
|
1e25ae4065 | ||
|
|
d26af6462f | ||
|
|
4ffac08775 | ||
|
|
9dee0bbd3c | ||
|
|
5ff67edaef | ||
|
|
eec6286a58 | ||
|
|
47e0d7cd1e | ||
|
|
fc54c3b946 | ||
|
|
8e5b9790f6 | ||
|
|
a0c8696987 | ||
|
|
77e7e54949 | ||
|
|
ce27829811 | ||
|
|
17e3b51629 | ||
|
|
313d0afff7 | ||
|
|
c83d109a75 | ||
|
|
72d2b17842 | ||
|
|
d93caa3539 | ||
|
|
49d4e17d67 | ||
|
|
c0b710e56f | ||
|
|
d23a83d066 | ||
|
|
10ea43dc9f | ||
|
|
c7f2105495 | ||
|
|
14f3b85865 | ||
|
|
e75e2ebb5e | ||
|
|
316b8c7583 | ||
|
|
c89c108098 | ||
|
|
4058a817dd |
@@ -5,6 +5,9 @@ VITE_DB_KEY=
|
||||
# Set to true/1/yes/on for local development. Default is disabled.
|
||||
VITE_AUTO_LOGIN=false
|
||||
|
||||
# Show the scheduled report debug notification test action. Default is disabled.
|
||||
VITE_SCHEDULED_REPORT_DEBUG=false
|
||||
|
||||
# AI API Configuration (Optional, can be entered in UI)
|
||||
# 注意:发布版本不再自动读取以下环境变量。
|
||||
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||
|
||||
@@ -31,10 +31,9 @@ jobs:
|
||||
cache-dependency-path: services/wechat-connector/go.sum
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Type check
|
||||
@@ -64,6 +63,8 @@ jobs:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Platform visual regression
|
||||
# GitHub-hosted Windows 无法稳定容纳项目统一的 1400×800 Electron 窗口,Windows visual regression 改由可控 Windows 桌面环境执行。
|
||||
if: matrix.os != 'windows-latest'
|
||||
run: pnpm exec playwright test tests/e2e/visual.spec.ts
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
electron.vite.config.[0-9]*.mjs
|
||||
dist
|
||||
out
|
||||
.env
|
||||
@@ -10,14 +11,18 @@ 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
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
AGENTS.md
|
||||
skills-lock.json
|
||||
*__screenshots__
|
||||
|
||||
/AGENTS.md
|
||||
/CLAUDE.md
|
||||
/.claude/
|
||||
/.workbuddy/
|
||||
/.ai-local/
|
||||
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
|
||||
@@ -2,3 +2,4 @@ singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
endOfLine: auto
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
},
|
||||
"files.eol": "\n"
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# 参与贡献
|
||||
|
||||
> 这份文档同时写给人和 AI Agent。文末有给 Agent 的英文硬性规则。
|
||||
> 如果你只是想把 TraceMemo 跑起来,看[第一次使用](./docs/user-guide/getting-started.md)就够了。
|
||||
|
||||
## 一句话规则
|
||||
|
||||
**从 `develop` 拉分支,把 PR 提给 `develop`。**
|
||||
|
||||
## 为什么不是 main
|
||||
|
||||
| 分支 | 是什么 | 接受 PR 吗 |
|
||||
| --------- | ------------------------------------------------------- | -------------- |
|
||||
| `main` | 稳定版。只在发版时更新,对应 GitHub Releases 里的安装包 | 不接受 |
|
||||
| `develop` | 开发主线。所有改动先进这里,**随下一个版本一起发布** | 唯一的目标分支 |
|
||||
|
||||
指向 `main` 的 PR、或者不是基于 `develop` 拉出来的分支,会被直接关闭。不是不欢迎贡献,而是因为冲突合并 提交落后较多等原因
|
||||
|
||||
## 提 PR 的完整流程
|
||||
|
||||
```bash
|
||||
# 1. 基于 develop 拉分支(不要基于 main)
|
||||
git fetch origin
|
||||
git checkout -b feat/your-change origin/develop
|
||||
|
||||
# 2. 改代码,只改与本任务相关的文件
|
||||
|
||||
# 3. 自检
|
||||
pnpm install # 需要 Node 22 与 pnpm 7.33.7
|
||||
pnpm typecheck
|
||||
pnpm test:unit # 再按改动范围补跑 component / integration
|
||||
|
||||
# 4. 提交
|
||||
git commit -m "feat: 一句话说明改了什么"
|
||||
|
||||
# 5. 提 PR,目标分支必须是 develop
|
||||
gh pr create --base develop --head feat/your-change
|
||||
```
|
||||
|
||||
## 提交信息
|
||||
|
||||
默认**只写一行标题**:类型前缀 + 一句话说明。
|
||||
|
||||
```
|
||||
feat: 新增本地查询能力
|
||||
fix: 修复查询工具时间契约
|
||||
docs: 整理开发文档
|
||||
```
|
||||
|
||||
确实包含多个功能点时,标题之后每个功能点各写一行纯文本 —— 不要用列表符号,也不要写
|
||||
「设置侧:」「测试:」这类分节标题:
|
||||
|
||||
```
|
||||
feat: 统一手动发送入口为文字转语音
|
||||
移除普通文本、图片和本地语音的手动发送入口
|
||||
保留文字转语音的生成、试听和发送能力
|
||||
```
|
||||
|
||||
不要在提交信息里堆文件名清单、测试结果或实现过程叙述,那些属于 PR 描述。
|
||||
|
||||
## 分支命名
|
||||
|
||||
`feat/…`、`fix/…`、`docs/…`、`refactor/…`,后面接简短的英文或拼音描述。
|
||||
|
||||
## PR 前自检
|
||||
|
||||
| 你的改动 | 至少跑这些 |
|
||||
| -------------------------- | ----------------------------------------------------------------- |
|
||||
| 一般代码 / 服务 / 工具函数 | `pnpm typecheck` + 相关 `pnpm test:unit` |
|
||||
| 界面 / 交互 | `pnpm typecheck` + 相关 `pnpm test:component`,或针对该功能的 E2E |
|
||||
| preload / IPC 契约 | `pnpm typecheck` + 相关 contract 测试或 `pnpm test:integration` |
|
||||
| 只改文档 | 不需要跑测试 |
|
||||
|
||||
全部测试命令见 `package.json` 的 `scripts`;本地开发环境的说明见[开发概览](./docs/development/overview.md)。
|
||||
|
||||
## 请不要提交这些东西
|
||||
|
||||
- **构建产物**:`out/`、`dist/`、`build/` 下的二进制、`node_modules/`、`test-results/`、`playwright-report/`
|
||||
- **真实数据**:微信聊天内容、真实的 wxid / 群名 / 联系人名、聊天截图
|
||||
- **密钥**:API Key、Token、数据库密钥、图片解密密钥
|
||||
- **本机绝对路径**:`/Users/…`、`C:\Users\…`
|
||||
|
||||
测试用的假数据请用一眼能看出是合成的命名,例如 `fixture-group`、`wxid_fixture_member`。
|
||||
|
||||
## 合入之后
|
||||
|
||||
PR 合进 `develop` 不会立刻出现在下载页,它随**下一个版本**发布。想提前用上,可以自行从
|
||||
`develop` 构建。
|
||||
|
||||
## 如果你是 AI Agent
|
||||
|
||||
请按顺序执行,不要凭直觉选分支:
|
||||
|
||||
1. **基线**:`git fetch origin && git checkout -b <branch> origin/develop`。永远不要基于 `main`。
|
||||
2. **PR 目标**:`gh pr create --base develop`。无法确定时默认 `develop`;任何情况下都不要把
|
||||
`main` 当 PR 目标。
|
||||
3. **改动范围**:只改与本任务相关的文件。不要顺手格式化、重排 import、升级依赖。
|
||||
4. **提交前**:跑 `pnpm typecheck` 和与本次改动相关的测试(见上表)。
|
||||
5. **提交信息**:一行标题,`type: 描述`。不要写文件清单、测试输出或过程叙述。
|
||||
6. **PR 描述**:说明改了什么、为什么改、怎么验证的;关联 Issue 用 `closes #123`。
|
||||
7. **禁止**:真实聊天数据、密钥、Token、本机绝对路径、构建产物。
|
||||
|
||||
### Hard rules for AI agents (English)
|
||||
|
||||
- Base branch: `origin/develop`. Never branch off `main`.
|
||||
- Open pull requests with base branch `develop`. PRs targeting `main` are closed without review.
|
||||
- One PR = one logical change. No drive-by reformatting, import reordering, or dependency upgrades.
|
||||
- Before opening a PR, run `pnpm typecheck` plus the tests relevant to your change.
|
||||
- Commit subject: a single line, `type: summary`. No file lists, no test logs, no process narration.
|
||||
- Never commit build output (`out/`, `dist/`, `test-results/`, `playwright-report/`), real WeChat
|
||||
data, keys, tokens, or absolute local paths.
|
||||
- Changes merged into `develop` ship with the next release.
|
||||
@@ -4,79 +4,87 @@
|
||||
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
|
||||
</p>
|
||||
|
||||
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
||||
<h2 align="center">把微信里的信息,记住、理解、监控,并在需要时行动</h2>
|
||||
|
||||
<p align="center">本地优先的微信数据、AI 分析与自动化工作台</p>
|
||||
|
||||
<p align="center">
|
||||
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
|
||||
查看聊天 · 找回信息 · AI 问答 · 群聊日报总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/TraceMemo?style=for-the-badge" alt="GitHub stars" />
|
||||
<img src="https://img.shields.io/github/downloads/Wxw-Gu/TraceMemo/total?style=for-the-badge" alt="GitHub downloads" />
|
||||
<img src="https://img.shields.io/github/v/release/Wxw-Gu/TraceMemo?style=for-the-badge" alt="Latest release" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
|
||||
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
|
||||
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
|
||||
<a href="https://github.com/Wxw-Gu/TraceMemo/releases"><b>下载 TraceMemo</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||
·
|
||||
<a href="./docs/README.md"><b>完整文档</b></a>
|
||||
·
|
||||
<a href="./docs/concepts/how-it-works.md"><b>TraceMemo 如何工作</b></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
|
||||
<img src="./public/日报.png" alt="TraceMemo 主界面" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" />
|
||||
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
|
||||
</p>
|
||||
---
|
||||
|
||||
## TraceMemo(迹忆)是什么
|
||||
## 🎨 社区日报模板
|
||||
|
||||
TraceMemo(迹忆)是一款**本地优先、可追溯的 AI 微信知识与分析工作台**。
|
||||
TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多样式。社区模板与默认日报读取同一份真实日报数据,只改变展示方式,适合手机长图分享、桌面归档、团队复盘等不同场景。
|
||||
|
||||
TraceMemo 原名 **WechatExplorer**,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates"><b>浏览 TraceMemo 模板社区</b></a>
|
||||
·
|
||||
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor"><b>用 AI 制作并投稿模板</b></a>
|
||||
</p>
|
||||
|
||||
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
|
||||
在 TraceMemo 中打开:
|
||||
|
||||
你可以直接浏览聊天,也可以用自然语言提问:
|
||||
**日报 → 今日日报 → 日报模板 → 模板市场**
|
||||
|
||||
> “上个月我们讨论过哪些发布问题?”
|
||||
>
|
||||
> “张三之前发过的项目地址在哪里?”
|
||||
>
|
||||
> “技术交流群今天有哪些结论和待办?”
|
||||
即可查看、预览、安装和切换已发布的社区模板。
|
||||
|
||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。
|
||||
如果你有一张喜欢的日报长图、网页或前端项目,也可以把它交给 Codex、ChatGPT 或其他能够读取 GitHub 仓库的 AI,并让它读取 [TraceMemo Template Contributor Skill](https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor)。AI 可以帮助你完成模板转换、真实预览,并在你确认满意后向 [TraceMemo-Templates](https://github.com/Wxw-Gu/TraceMemo-Templates) 提交 Pull Request。
|
||||
|
||||
你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
||||
|
||||
TraceMemo 不提供任何微信聊天数据,也不鼓励收集、上传、出售、共享或未经授权处理他人的聊天记录。使用 TraceMemo 时,请确保你对所处理的数据具有合法的访问和使用权限,并自行承担相应的数据安全与合规责任。
|
||||
模板通过审核并正式发布后,其他 TraceMemo 用户即可在模板市场中安装使用。
|
||||
|
||||
---
|
||||
|
||||
## 为什么叫 TraceMemo(迹忆)
|
||||
## TraceMemo 是什么
|
||||
|
||||
<details>
|
||||
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
|
||||
TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数据、AI 分析与自动化工作台,把聊天变成可浏览、可搜索、可理解、可追溯的信息。
|
||||
|
||||
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
|
||||
先用档案找原话,再按需要使用 AI Search、日报、监控或 Agent。普通浏览、搜索和导出不需要 AI。
|
||||
|
||||
“迹忆”可以理解为“留下痕迹的记忆”。
|
||||
## 核心能力
|
||||
|
||||
TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
|
||||
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
|
||||
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
|
||||
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
|
||||
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
|
||||
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
|
||||
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
|
||||
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
|
||||
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
|
||||
|
||||
> **品牌说明**
|
||||
>
|
||||
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
|
||||
## 💻 平台支持
|
||||
|
||||
</details>
|
||||
TraceMemo 2.4.0 支持:
|
||||
|
||||
---
|
||||
- **Windows x64**
|
||||
- **macOS Apple Silicon(M 系列 / arm64)**
|
||||
- **macOS Intel(x64)**
|
||||
|
||||
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
|
||||
|
||||
## 项目缘起
|
||||
|
||||
@@ -88,11 +96,11 @@ TraceMemo 最早叫 **WechatExplorer**。
|
||||
|
||||
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
|
||||
|
||||
为了支持微信 4.x,我开始重新研究数据访问。这部分工作得到了 **WeFlow** 很大的帮助。TraceMemo 目前的微信 4.x 数据连接能力,参考并使用了 **WeFlow 历史版本中的相关实现和思路**,包括数据库密钥获取、图片解密等底层能力。
|
||||
为了支持微信 4.x,我开始重新研究数据访问。这部分工作最初得到了 **WeFlow** 很大的帮助。早期 TraceMemo 曾参考 WeFlow 历史版本中的实现和思路,借此解决了数据库消息、密钥获取等微信 4.x 数据访问问题。
|
||||
|
||||
> **没有 WeFlow,就没有今天的 TraceMemo。**
|
||||
随着项目继续发展,我逐步把这部分底层能力从原有实现中抽离,并重新实现了一套独立的数据访问兼容层。目前会继续保持与 WeFlow 历史接口和行为的兼容,以减少上层业务迁移成本。
|
||||
|
||||
WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继续做后面的事情:让聊天记录可以被搜索、理解和总结,也让 AI 给出的答案能够回到原始消息核对。
|
||||
也就是说,**WeFlow 是 TraceMemo 进入微信 数据访问领域的重要起点。没有 WeFlow,就没有今天的 TraceMemo。**
|
||||
|
||||
在此基础上,项目陆续加入了:
|
||||
|
||||
@@ -105,12 +113,15 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
|
||||
- Reader Skill
|
||||
- Agent 接入
|
||||
- 多种聊天记录导出能力
|
||||
- 退群监控
|
||||
- 文字转语音
|
||||
- 持续监控自动化能力
|
||||
|
||||
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
|
||||
|
||||
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
|
||||
|
||||
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。
|
||||
感谢每一位使用、关注和反馈过的人。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -124,298 +135,54 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
|
||||
|
||||
## 从你的任务开始
|
||||
|
||||
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
|
||||
| 找一句记得原文或关键词的聊天 | [档案](./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="问问微信与聊天来源" />
|
||||
</p>
|
||||
|
||||
详细说明:[使用 AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
||||
|
||||
### 直接在微信里问你的历史聊天
|
||||
|
||||
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。
|
||||
|
||||
例如,你可以直接给机器人发送:
|
||||
|
||||
- “最近 5 个会话”
|
||||
- “张三最近和我聊了什么”
|
||||
- “总结今天的技术交流群”
|
||||
|
||||
TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
|
||||
|
||||
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。
|
||||
|
||||
当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
|
||||
|
||||
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
|
||||
|
||||
---
|
||||
|
||||
## 其他能力
|
||||
|
||||
### 本地知识库
|
||||
|
||||
<details>
|
||||
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。
|
||||
|
||||
它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
|
||||
|
||||
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
|
||||
|
||||
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 实验性:生成微信分享卡片
|
||||
|
||||
<details>
|
||||
TraceMemo 可以把群聊日报长图上传到你自己部署的 Cloudflare Worker 和 R2,并生成可在微信中分享的临时网页、二维码及卡片信息。
|
||||
|
||||
该功能需要自备 Cloudflare 账号、域名和微信测试号,目前不属于开箱即用的稳定功能。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享效果示例" />
|
||||
</p>
|
||||
|
||||
详细说明:[实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)。
|
||||
|
||||
不熟悉命令行的用户,可以把[自动部署 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>
|
||||
支持 HTML、CSV、JSON 和 Markdown。
|
||||
|
||||
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 首次连接可能需要按页面提示完成额外授权。
|
||||
|
||||
---
|
||||
| 想做什么 | 使用入口 |
|
||||
| ---------------------------------- | ----------------------------- |
|
||||
| 找记得原文或关键词的消息 | 档案搜索 |
|
||||
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
|
||||
| 长期跨群查询历史 | 本地知识库 |
|
||||
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
|
||||
| 持续关注群成员退出 | 退群监控 |
|
||||
| 按计划生成并发送群聊日报 | 定时日报 |
|
||||
| 把文字生成微信语音 | 文字转语音 |
|
||||
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
|
||||
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
|
||||
| 把聊天保存成文件 | 导出 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
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”。
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载对应平台的安装包。
|
||||
2. 启动应用,按“第一次使用”页面选择微信数据目录并完成连接。
|
||||
3. 打开“档案”,确认联系人和消息已加载后开始搜索。
|
||||
4. 需要 AI 时,在“设置 → AI 模型”添加并测试 Provider。
|
||||
|
||||
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)。
|
||||
|
||||
---
|
||||
详细步骤见[第一次使用 TraceMemo](./docs/user-guide/getting-started.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)
|
||||
- [用户指南](./docs/README.md#用户指南)
|
||||
- [AI / Knowledge](./docs/README.md#ai-与知识库)
|
||||
- [Monitor / Automation](./docs/README.md#日报与自动化)
|
||||
- [Agent / API](./docs/README.md#agent--api)
|
||||
- [开发文档](./docs/development/overview.md)
|
||||
- [隐私与安全](./docs/user-guide/privacy.md)
|
||||
|
||||
---
|
||||
完整目录由[文档首页](./docs/README.md)维护。
|
||||
|
||||
## 本地开发
|
||||
## 支持平台
|
||||
|
||||
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
|
||||
| 平台 | 架构 | 微信连接 | 安装包 |
|
||||
| ------- | ------------------------------ | ------------------------------------- | ------------------------------- |
|
||||
| Windows | x64 | 支持微信 4.x | `tracememo-<version>-setup.exe` |
|
||||
| macOS | Apple Silicon(M 系列、arm64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-arm64.dmg` |
|
||||
| macOS | Intel(x64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-x64.dmg` |
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
## 参与贡献
|
||||
|
||||
常用检查:
|
||||
稳定版在 `main`,只在发版时更新;所有改动都先进 `develop`,随**下一个版本**一起发布。
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
**提 PR 请基于 `develop` 拉新分支,并把 PR 的目标分支设为 `develop`** —— 指向 `main` 的 PR 会被直接关闭。
|
||||
|
||||
完整说明:[开发、测试与构建](./docs/development/overview.md)
|
||||
|
||||
---
|
||||
|
||||
## 支持与反馈
|
||||
|
||||
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
|
||||
|
||||
提交 Issue 时请提供:
|
||||
|
||||
- 操作系统
|
||||
- 微信版本
|
||||
- TraceMemo 版本
|
||||
- 复现步骤
|
||||
- 已遮挡敏感信息的截图
|
||||
|
||||
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
|
||||
|
||||
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
|
||||
|
||||
---
|
||||
|
||||
## 许可说明
|
||||
|
||||
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件。
|
||||
|
||||
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
|
||||
|
||||
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
|
||||
|
||||
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
|
||||
|
||||
---
|
||||
分支流程、提交信息风格、PR 前自检,以及**给 AI Agent 的硬性规则**,都在[参与贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
## 致谢
|
||||
|
||||
@@ -423,22 +190,33 @@ TraceMemo 的诞生离不开开源社区中许多优秀项目的工作。
|
||||
|
||||
### 特别感谢 WeFlow
|
||||
|
||||
TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库密钥获取、图片解密等底层能力。
|
||||
TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库访问、密钥获取等底层能力。
|
||||
|
||||
特别感谢作者 **hicccc77** 的理解和包容。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
|
||||
特别感谢作者 **[hicccc77](https://github.com/hicccc77)**。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
|
||||
|
||||
### 其他参考项目
|
||||
|
||||
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
||||
- 提供了数据库解析相关思路。
|
||||
- 提供了数据解析相关思路。
|
||||
|
||||
- **[chatlog](https://github.com/sjzar/chatlog)**
|
||||
- 提供了数据处理方面的参考。
|
||||
|
||||
- **[wechat_chatter](https://github.com/yincongcyincong/wechat_chatter)**
|
||||
- 提供了发送方面的参考。
|
||||
|
||||
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
|
||||
|
||||
---
|
||||
|
||||
## 最后说两句
|
||||
|
||||
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
|
||||
|
||||
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
|
||||
|
||||
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
|
||||
|
||||
<p align="center">
|
||||
<b>TraceMemo(迹忆)</b>
|
||||
<br />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: TraceMemo
|
||||
releaseType: release
|
||||
updaterCacheDirName: tracememo-updater
|
||||
@@ -1,64 +1,69 @@
|
||||
# TraceMemo 文档
|
||||
|
||||
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
||||
文档按产品任务和使用场景组织。需要集成或开发时,再看 Agent、概念和开发文档。
|
||||
|
||||
## 从这里开始
|
||||
## 档案与搜索
|
||||
|
||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。
|
||||
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。
|
||||
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。
|
||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
|
||||
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
|
||||
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
|
||||
|
||||
## 你可以完成的任务
|
||||
## AI 与知识库
|
||||
|
||||
- [建立本地知识库](./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 Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
|
||||
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
|
||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
|
||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
|
||||
|
||||
## 如果你想了解 AI 为什么这样回答
|
||||
## 日报与自动化
|
||||
|
||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。
|
||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。
|
||||
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
|
||||
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
|
||||
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
|
||||
|
||||
## 微信机器人和外部 Agent
|
||||
## Monitor
|
||||
|
||||
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
|
||||
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
|
||||
|
||||
| 你想做什么 | 应该看哪里 |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 在微信里给机器人发消息,让本机读取数据、生成总结并回复 | [Agent Hub](./agent/agent-hub.md) |
|
||||
| 在 Codex、Claude Code、OpenClaw 等外部 Agent 中主动查询过去的微信数据 | [Reader Skill](./agent/reader-skill.md) + [Local HTTP API](./agent/api.md) |
|
||||
## 语音能力
|
||||
|
||||
### 在微信里提问
|
||||
- [语音转文字](./user-guide/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
|
||||
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
|
||||
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”后扫码登录微信机器人。机器人收到文字消息后,可以查询最近会话、读取联系人聊天、生成群聊总结图片或总结群成员发言,并把结果回复给发消息的人。它需要本地微信数据库已经连接;依赖 AI 的任务还需要配置 AI 服务。
|
||||
## Agent / API
|
||||
|
||||
- [Agent Hub](./agent/agent-hub.md):连接机器人、查看运行状态和了解实时交互边界。
|
||||
Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP API 让外部 Agent 主动查询历史数据。
|
||||
|
||||
### 让外部 Agent 查询历史微信
|
||||
- [Agent 接入概览](./agent/overview.md)
|
||||
- [Agent Hub](./agent/agent-hub.md)
|
||||
- [Reader Skill](./agent/reader-skill.md)
|
||||
- [Local HTTP API](./agent/api.md)
|
||||
- [API 安全](./agent/api-security.md)
|
||||
|
||||
连接 Reader Skill 后,你可以询问:
|
||||
## 导出与隐私
|
||||
|
||||
> “总结今天技术交流群讨论了什么。”
|
||||
> “过去一周有没有人提到这个项目?”
|
||||
- [导出聊天](./user-guide/export.md):导出 HTML、Markdown、CSV 或 JSON 档案。
|
||||
- [数据、隐私与安全](./user-guide/privacy.md):本地处理、Provider、媒体和 Token 的数据边界。
|
||||
- [常见问题与排查](./user-guide/troubleshooting.md):按安装、连接、AI、媒体和 Agent 现象排查。
|
||||
|
||||
- [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)
|
||||
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.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)
|
||||
- [macOS 数据访问说明](./platform/macos.md)
|
||||
- [关闭 SIP 教程](./mac-disable-sip.md)
|
||||
|
||||
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
## 实验性功能与第三方
|
||||
|
||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
||||
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
|
||||
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
|
||||
|
||||
## 版本说明
|
||||
|
||||
- [v2.2.0 品牌与安全迁移](./agent/release-notes-v2.2.0.md)
|
||||
- [v2.1.9 API 鉴权迁移](./agent/release-notes-v2.1.9.md)
|
||||
|
||||
文档按当前 develop 已实现的能力维护,不在首页固定写死版本号。版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
|
||||
@@ -36,6 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
@@ -84,13 +85,114 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
- `200`:请求成功;
|
||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||
- `400`:参数或 JSON 请求体无效;
|
||||
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
|
||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||
- `404`:端点、会话或群聊不存在;
|
||||
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
|
||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||
- `500`:服务端处理或报告渲染失败。
|
||||
|
||||
成功响应会返回端点对应的 JSON 对象,例如 `chatlog` 包含 `contact`、`query`、`count` 和 `messages`,`contact` 返回 `count` 与 `contacts`。
|
||||
|
||||
图片消息在 `messages` 中保留原有字段,并额外提供 `media`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "图片",
|
||||
"content": "",
|
||||
"media": {
|
||||
"type": "image",
|
||||
"available": true,
|
||||
"url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
|
||||
|
||||
`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。
|
||||
|
||||
## 与 MCP 的关系
|
||||
|
||||
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
||||
|
||||
## LLM-friendly Query Tool API
|
||||
|
||||
这些端点提供稳定的结构化 Query primitive,不接收自然语言问题,也不会调用 AI。它们与现有 API 共用端口、Bearer Token、loopback 和 CORS 安全策略。
|
||||
|
||||
```bash
|
||||
BASE="http://127.0.0.1:6131/api/v1"
|
||||
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
|
||||
|
||||
# 能力目录
|
||||
curl -H "$AUTH" "$BASE/query/capabilities"
|
||||
|
||||
# BOBO 的第一条真实互动
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"direction":"any","order":"asc","limit":1,"excludeSystem":true}'
|
||||
|
||||
# 上个月 BOBO 发来的文件
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"},"direction":"from_target","messageTypes":["file"],"order":"desc","limit":1}'
|
||||
|
||||
# 受限语义关键词检索(最多 4 个 variants)
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/search" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"query":"答应之后给我或者帮我完成某件事情","variants":["我给你","我发你","弄好给你"],"limit":20}'
|
||||
|
||||
# 按会话和时间范围提取可供总结的证据
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/conversation-overview" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"}}'
|
||||
```
|
||||
|
||||
`query/messages` 的 `messageRef` 是服务端生成的不透明引用,可直接传给 `query/message-context` 获取前后文;不要自行构造 wxid、md5 或数据库路径。
|
||||
|
||||
每条消息都会返回 `messageType`(`text`、`image`、`voice`、`video`、`file`、`link`、`sticker`、`system` 或 `other`)。非文本消息不会伪造 `text`;可识别的图片、视频、贴纸和文件会返回不含密钥或本地路径的 `attachment` 元数据。
|
||||
|
||||
`conversation-overview` 同时返回 `sourceCoverage` 与 `selection`:前者描述时间范围内源消息是否完整及 `sourceMessageCount`,后者描述从源消息中选出的 Evidence 数量及是否抽样。`evidence` 最终按 `timestamp` 升序返回,`messageRef` 是唯一推荐的消息引用。
|
||||
`conversation-overview` 另有一个 `origin` 字段:`wcdb` 表示这次证据直接来自本机聊天数据库(会话概览的事实来源),`knowledge` 表示来自本地索引。
|
||||
|
||||
### 搜索范围(scope)
|
||||
|
||||
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
|
||||
|
||||
| scope | 含义 |
|
||||
| ----- | ---- |
|
||||
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
|
||||
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
|
||||
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
|
||||
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
|
||||
|
||||
`conversationId` 是会话标识,可用 `/api/v1/resolve` 或 `/api/v1/contact` 得到。`scope` 一旦给出就是**权威边界**:`target` 落在范围之外会被拒绝(`status: "invalid_tool_arguments"`、`constraint: "target_outside_scope"`),不会静默扩大范围;范围里包含多个会话时,`query/messages` 与 `query/conversation-overview` 必须显式指定 `target`(`constraint: "target_required_for_scope"`)。
|
||||
|
||||
响应会回显实际生效的边界:
|
||||
|
||||
```json
|
||||
{ "scope": { "kind": "groups", "conversationCount": 243 } }
|
||||
```
|
||||
|
||||
跨会话检索时,`evidence` 的每一项都会带上它所属的会话,便于把结果归属到具体群 / 联系人与具体成员:
|
||||
|
||||
```json
|
||||
{
|
||||
"messageRef": "…",
|
||||
"conversationName": "某个群",
|
||||
"conversationType": "group",
|
||||
"sender": "某成员",
|
||||
"timestamp": 1789099069000,
|
||||
"text": "…"
|
||||
}
|
||||
```
|
||||
|
||||
### 索引新鲜度(freshness)
|
||||
|
||||
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| ---- | ---- |
|
||||
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
|
||||
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
|
||||
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
|
||||
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
|
||||
|
||||
调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `pending` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。
|
||||
|
||||
`query/messages` 与 `query/conversation-overview` 直读聊天数据库,不受索引新鲜度影响。
|
||||
|
||||
@@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内
|
||||
|
||||
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
|
||||
|
||||
- Knowledge 正在同步时,新的分析会被暂停;
|
||||
- Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围;
|
||||
- 语音没有转写时,AI 可能只能看到消息类型;
|
||||
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
|
||||
- 你只选择了一个群,答案不会自动代表所有聊天。
|
||||
|
||||
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
|
||||
Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
|
||||
|
||||
## 这不是事实保证
|
||||
|
||||
|
||||
@@ -19,8 +19,45 @@ flowchart LR
|
||||
M[微信机器人消息] --> N[Agent Hub]
|
||||
N --> B
|
||||
N --> F
|
||||
B --> O[Monitor / Snapshot]
|
||||
O --> P[Proposed Action]
|
||||
F --> P
|
||||
P --> Q[Policy]
|
||||
Q --> R[Action Gateway]
|
||||
R --> S[Personal WeChat Send Capability]
|
||||
S --> T[Action Audit / Logs]
|
||||
```
|
||||
|
||||
## Remember → Understand → Monitor → Act
|
||||
|
||||
TraceMemo 的工作方式可以概括为:
|
||||
|
||||
```text
|
||||
Remember → Understand → Monitor → Act
|
||||
```
|
||||
|
||||
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
|
||||
|
||||
## 退群监控
|
||||
|
||||
退群监控使用成员快照判断变化:
|
||||
|
||||
```text
|
||||
Current Membership → Snapshot Diff → Member Event
|
||||
```
|
||||
|
||||
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
|
||||
|
||||
## 动作执行与审计
|
||||
|
||||
自动发送和监控动作经过统一边界:
|
||||
|
||||
```text
|
||||
Feature → Policy → Gateway → Capability → Execution → Audit
|
||||
```
|
||||
|
||||
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
|
||||
|
||||
## 哪些步骤在本机
|
||||
|
||||
- 微信数据库读取与解析;
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
|
||||
`6131` 是应用提供给本机集成使用的 API 端口,不是 Vite 的页面端口。
|
||||
|
||||
## WCDB 消息读取诊断
|
||||
|
||||
`WCDB_DEBUG_LOGS` 默认关闭。需要排查消息读取链路时,可以在启动命令前设置为 `1`:
|
||||
|
||||
```bash
|
||||
WCDB_DEBUG_LOGS=1 pnpm dev
|
||||
```
|
||||
|
||||
开启后会输出 `GETMSG-xxx` 请求耗时和 native `WCDB-EXPLAIN` 执行计划,不记录聊天正文。取消该环境变量或设为 `0` 即可关闭。
|
||||
|
||||
## Go 命令找不到
|
||||
|
||||
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
|
||||
@@ -28,25 +38,24 @@ go version
|
||||
|
||||
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
|
||||
|
||||
项目的 [`.npmrc`](../../.npmrc) 已设置:
|
||||
**先看根因,别急着删 `node_modules` 重装。** `electron@43` 的 npm 包**不再声明 `postinstall`**(其 `package.json` 里 `scripts` 是空对象),下载改为「首次 `require('electron')` 时的懒加载」。因此:
|
||||
|
||||
```ini
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
```
|
||||
- `package.json` 里的 `pnpm.onlyBuiltDependencies: ["electron"]` 对它不起作用——上游没有脚本可执行,pnpm 无从下手;
|
||||
- `pnpm install` 跑完不会有任何二进制被下载,**只重装依赖解决不了这个问题**。
|
||||
|
||||
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
|
||||
项目已自动兜住这条路径:`scripts/ensure-electron-binary.cjs` 挂在 `postinstall` 与 `predev` 上,校验 `path.txt` 指向的可执行文件是否真的存在(只有 `path.txt` 而没有 `dist/` 同样算没装好),缺失时就地补下载。它读取 `.npmrc` 的 `electron_mirror`(当前为 `https://npmmirror.com/mirrors/electron/`),失败后再兜底重试一次该镜像。
|
||||
|
||||
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
|
||||
正常情况下你不需要做任何事。只有当自动步骤没有执行时(例如安装时带了 `--ignore-scripts`),才需要手动补一次:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
node scripts/ensure-electron-binary.cjs
|
||||
```
|
||||
|
||||
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
|
||||
要换用别的镜像时,显式设置环境变量(优先于 `.npmrc`)。PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
|
||||
pnpm install --frozen-lockfile
|
||||
node scripts/ensure-electron-binary.cjs
|
||||
```
|
||||
|
||||
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
|
||||
|
||||
@@ -34,17 +34,17 @@ pnpm test:e2e:build
|
||||
|
||||
## 代码变更对应文档
|
||||
|
||||
| 代码区域 | 需要同步检查的文档 |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `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` |
|
||||
| 代码区域 | 需要同步检查的文档 |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `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/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` |
|
||||
|
||||
## 文档检查
|
||||
|
||||
@@ -52,7 +52,7 @@ pnpm test:e2e:build
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!DOCUMENTATION_AUDIT.md' --glob '!development/overview.md'
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
|
||||
```
|
||||
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Query Agent POC
|
||||
|
||||
这是独立的开发测试入口,不会修改生产“问问微信”执行链。
|
||||
|
||||
先启动 TraceMemo,并在 API Center 开启 Local HTTP API。然后在仓库根目录运行:
|
||||
|
||||
```bash
|
||||
pnpm poc:query-agent "我和BOBO第一次聊了什么"
|
||||
```
|
||||
|
||||
## 两个入口
|
||||
|
||||
| 命令 | 行为 | 何时用 |
|
||||
| --- | --- | --- |
|
||||
| `pnpm poc:query-agent "问题"` | 先执行完整构建,再运行 | 首次运行,或刚改过代码 |
|
||||
| `pnpm poc:query-agent:run "问题"` | 直接运行已有构建,**不构建** | 连续迭代测试 |
|
||||
|
||||
`poc:query-agent:run` 在构建产物不存在时会明确提示先运行 `pnpm poc:query-agent`,**不会自动构建**。
|
||||
|
||||
注意:`poc:query-agent` 内部走的是完整 `electron-vite build`(main + preload + renderer),
|
||||
即使 POC 只需要一个 main entry。连续测试请使用 `poc:query-agent:run` 以免每次都重建整个 renderer。
|
||||
|
||||
## 传参
|
||||
|
||||
参数按原样转发给入口,可以被 `--` 分隔(`pnpm run` 惯例):
|
||||
|
||||
```bash
|
||||
pnpm poc:query-agent -- "BOBO上个月有没有给我发过文件"
|
||||
pnpm poc:query-agent:run "BOBO上个月有没有给我发过文件"
|
||||
```
|
||||
|
||||
入口只会移除参数列表**开头**的一个独立 `--`;问题正文中的 `--` 会原样保留。
|
||||
|
||||
## Provider
|
||||
|
||||
POC 使用设置页当前默认 AI Provider、模型、Base URL 和安全存储中的 API Key。Local Query API 仍使用现有 Bearer Token;POC 输出不会打印 Token、API Key、数据库路径或内部消息 ID。
|
||||
|
||||
## 输出
|
||||
|
||||
**stdout 是 JSON**(`poc:query-agent` 会在它前面混入构建日志,`poc:query-agent:run` 只多两行 pnpm 横幅)。
|
||||
需要机器解析时用 `--silent` 拿到纯 JSON:
|
||||
|
||||
```bash
|
||||
pnpm --silent poc:query-agent:run "我和BOBO第一次聊了什么" > result.json
|
||||
```
|
||||
|
||||
JSON 字段:
|
||||
|
||||
- `question`、`provider`、`model`
|
||||
- `modelCallCount`、`toolCallCount`
|
||||
- `modelDurationsMs`(每次模型调用耗时,含失败的那次)
|
||||
- `modelDiagnostics`(每次模型调用的请求级诊断:HTTP status、content-type、是否返回 HTML、是否超时、耗时)
|
||||
- `firstModelMs`、`toolTotalMs`、`finalModelMs`、`totalMs`
|
||||
- 每次工具调用的名称、脱敏参数、耗时、状态和结果数量
|
||||
- 最终 `answer` 或错误信息
|
||||
|
||||
**stderr 是人类可读摘要**(不参与 JSON 解析):
|
||||
|
||||
```text
|
||||
[Timing]
|
||||
Model #1 1315 ms
|
||||
TM Tools(1) 623 ms
|
||||
Model #2 1598 ms
|
||||
------------------------
|
||||
Total 3545 ms
|
||||
Model total 2913 ms (82.2%)
|
||||
TM tool total 623 ms (17.6%)
|
||||
|
||||
[Provider]
|
||||
provider DeepSeek
|
||||
model DeepSeek Chat
|
||||
host api.deepseek.com
|
||||
model calls 2
|
||||
tool calls 1
|
||||
attempt #1 elapsedMs=1298 status=200 contentType=application/json
|
||||
attempt #2 elapsedMs=1571 status=200 contentType=application/json
|
||||
```
|
||||
|
||||
`elapsedMs` 是 TTFB(收到响应头),`modelDurationsMs` 是整次调用(含读 body);502 时两者接近,
|
||||
说明等待发生在上游网关,不是本地读 body 慢。诊断只记录 host,不记录完整 URL 或任何凭据。非 2xx 响应会先记录 status / content-type / elapsedMs,再返回安全错误(例如“模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)”),不会把 HTML 正文丢给 JSON 解析器。
|
||||
|
||||
## 约束
|
||||
|
||||
工具调用最多 5 次,只允许 `query_messages`、`search_messages`、`message_context`、`conversation_overview`。未配置 AI Provider、Local Query API 未启动或当前 Provider 协议不支持 tools 时,POC 会直接返回错误,不会回退到另一套模型配置。
|
||||
@@ -23,5 +23,16 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
|
||||
|
||||
## Intel 与 Apple Silicon
|
||||
|
||||
从 Releases 选择与 Mac 处理器匹配的构建。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
||||
TraceMemo 同时支持两种 Mac 架构:
|
||||
|
||||
- **Apple Silicon(M 系列、`arm64`)**
|
||||
- **Intel Mac(`x64`)**
|
||||
|
||||
从 Releases 下载与你 Mac 处理器匹配的构建:
|
||||
|
||||
- Apple Silicon:`tracememo-<版本号>-arm64.dmg`
|
||||
- Intel:`tracememo-<版本号>-x64.dmg`
|
||||
|
||||
两种架构都可以通过应用内的连接流程自动获取微信数据库密钥。首次连接时,TraceMemo 会根据当前机器架构进入对应的流程,按连接页面提示操作即可。
|
||||
|
||||
Apple Silicon 和 Intel 已适配微信 macOS `4.1.13`。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: tracememo-reader
|
||||
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
|
||||
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
|
||||
---
|
||||
|
||||
# TraceMemo Reader
|
||||
@@ -27,20 +27,70 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
|
||||
## 端点速查
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ---- | --------------------- | ------------------------------------------------- |
|
||||
| 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` | 已连接机器人发送测试 |
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ------ | ----------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/health` | 健康和数据库状态 |
|
||||
| GET | `/current_time` | 本机时间与时区 |
|
||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
||||
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
||||
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
||||
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
||||
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
||||
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
||||
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
|
||||
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
|
||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
||||
| POST | `/agent/send` | 已连接机器人发送测试 |
|
||||
|
||||
## 定时日报管理
|
||||
|
||||
定时日报由 TraceMemo 自己持久化和调度。Agent 只负责理解自然语言、解析群聊和时间,再调用上述 API;不要创建 cron、维护任务文件、计算下一次执行时间或自行发送微信。
|
||||
|
||||
### 创建任务
|
||||
|
||||
用户提出“每天早上 9 点给技术交流群发昨天的日报”时,按以下顺序执行:
|
||||
|
||||
1. 调用 `/health`,确认 TraceMemo 和数据库可用。
|
||||
2. 调用 `/wechat-personal/send-capability`,只有 `capability.status === "ready"` 且 `capability.capabilities.image === true` 才允许继续。
|
||||
3. 用户使用“今天”“昨天”等相对日期时调用 `/current_time`;日报任务的 `schedule.time` 使用 TraceMemo 本机时区的 `HH:mm`,不要转成 UTC。
|
||||
4. 调用 `/chatroom` 或 `/contact?type=group` 查找群聊。名称匹配多个结果时,必须把候选项展示给用户并要求选择;不能猜测。
|
||||
5. 使用唯一群聊的 `talker` 创建:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "技术交流群 · 每日日报",
|
||||
"group": { "talker": "xxx@chatroom", "name": "技术交流群" },
|
||||
"schedule": { "type": "daily", "time": "09:00" },
|
||||
"reportRange": "yesterday",
|
||||
"target": { "type": "wechat_group", "talker": "xxx@chatroom" },
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
|
||||
|
||||
### 查看、修改和执行
|
||||
|
||||
- “我现在有哪些定时日报”调用 `GET /scheduled-reports`,使用返回的 `tasks` 展示任务名称、群聊、每天的时间、范围、目标和启停状态。
|
||||
- 修改前先查询列表并确认唯一任务,再调用 `PATCH /scheduled-reports/:id`。只提交需要修改的字段,例如 `{"schedule":{"type":"daily","time":"10:00"}}`。
|
||||
- 暂停调用 `/scheduled-reports/:id/disable`,恢复调用 `/scheduled-reports/:id/enable`。
|
||||
- “现在执行一次”调用 `/scheduled-reports/:id/run`,不要改用 `/agent/group-report` 后自行发送微信;该接口和定时执行共用同一条链路。
|
||||
- 查询执行结果调用 `/scheduled-reports/:id/executions`,根据 `status`、`startedAt`、`finishedAt`、`message` 和 `error` 向用户解释结果。
|
||||
|
||||
### 删除确认
|
||||
|
||||
删除是不可逆操作。收到删除请求后,先用任务列表找到唯一任务,向用户展示任务名称、时间、日报范围和发送目标并明确询问确认;只有用户明确确认后,才调用 `DELETE /scheduled-reports/:id`。
|
||||
|
||||
## 时间与上下文规则
|
||||
|
||||
@@ -52,6 +102,29 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
- 根据多条消息整理出的总结;
|
||||
- 没有来源支持的推断。
|
||||
|
||||
## 媒体消息
|
||||
|
||||
当 `/chatlog` 返回图片消息时:
|
||||
|
||||
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
|
||||
2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。
|
||||
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
|
||||
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
|
||||
5. 如果图片获取失败,明确说明无法读取图片。
|
||||
6. 不要声称看到了没有成功获取的图片。
|
||||
7. 不要向用户暴露 Token、本地文件路径或数据库路径。
|
||||
|
||||
### 图片分析
|
||||
|
||||
用户:“看看张三昨天发的那张截图。”
|
||||
|
||||
1. 调用 `/health`;必要时调用 `/current_time`。
|
||||
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
|
||||
3. 请求该消息的 `media.url`,将返回的图片交给 Vision。
|
||||
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
|
||||
|
||||
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
|
||||
|
||||
## 隐私和安全
|
||||
|
||||
只读取用户请求所需的会话和时间范围。不要把完整聊天数据库、密钥或 Token 暴露给用户。Reader API 本身不自动把聊天转发到外部服务器,但当前 Agent 可能会把工具结果交给其配置的模型;如有疑问,提醒用户检查 Agent 的数据策略。
|
||||
@@ -60,6 +133,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
|
||||
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
||||
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
||||
- `404`:先用 `/resolve` 确认会话标识。
|
||||
- `404`:会话查询失败时先用 `/resolve` 确认会话标识;媒体请求表示标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在。
|
||||
- `422`:媒体标识格式错误,或消息不是可读取的图片(`NOT_IMAGE`)。
|
||||
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||
|
||||
@@ -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>.
|
||||
@@ -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`。
|
||||
@@ -41,6 +41,19 @@
|
||||
|
||||
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||
|
||||
## 查找过程和跳回原消息
|
||||
|
||||
查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。
|
||||
|
||||
查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。
|
||||
|
||||
点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息:
|
||||
|
||||
- 群聊来源打开的是那个群,而不是群里某个联系人;
|
||||
- 会加载该消息前后的上下文,并滚动到它、短暂高亮;
|
||||
- 只加载目标消息附近的一段,不会把整个会话历史全部读出来;
|
||||
- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。
|
||||
|
||||
## 什么时候不要直接相信答案
|
||||
|
||||
- 来源很少,或时间范围与问题不一致;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 选择要看的会话
|
||||
|
||||
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
||||
左侧会话列表可以浏览已读取到的联系人、群聊和公众号。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
||||
|
||||
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
|
||||
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
|
||||
|
||||
## 怎么找到联系人
|
||||
|
||||
档案搜索会综合多个身份字段匹配联系人或群聊,包括:
|
||||
|
||||
- 通讯录备注(remark);
|
||||
- 微信昵称;
|
||||
- 当前微信号;
|
||||
- wxid;
|
||||
- 拼音全拼和拼音首字母。
|
||||
|
||||
因此可以直接输入备注、昵称、微信号或拼音查找。搜索联系人和搜索消息是两步:先确认目标会话,再在会话内查关键词;记得大意但不知道原话时,改用[AI Search](./ai-search.md)。
|
||||
|
||||
## 文字转语音
|
||||
|
||||
在档案中选择当前联系人或群聊,输入文字后生成语音,试听确认后发送。这个入口只处理明确的文字转语音动作,不是任意文本、图片或本地语音文件发送器。
|
||||
|
||||
## 消息和媒体
|
||||
|
||||
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
|
||||
@@ -27,11 +43,7 @@
|
||||
|
||||
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
|
||||
|
||||
## 可选保留撤回消息
|
||||
|
||||
“设置 → 防撤回”提供一个默认关闭的可选功能。开启后,应用会尽量保留之后捕获到的撤回消息,并在气泡旁标记“消息已撤回”。它不能找回开启前已经消失或应用未捕获到的内容,也可能增加加载开销。
|
||||
|
||||
该功能与普通只读浏览的数据边界不同。开启前请阅读[防撤回](./recall-protection.md)。
|
||||
联系人和群聊列表会尽量显示头像、备注和昵称。头像或资料缺失时不影响消息读取;这通常表示本机没有对应资源,或微信没有返回完整资料。
|
||||
|
||||
## 保护自己不被误导
|
||||
|
||||
|
||||
@@ -8,12 +8,29 @@
|
||||
|
||||
## 1. 开始前准备
|
||||
|
||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
|
||||
TraceMemo 需要读取微信本地数据库。首次使用前,请确认已安装受支持的微信客户端,并按照连接页面完成数据库密钥获取。
|
||||
|
||||
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
|
||||
| 系统 | 微信客户端 | 首次连接说明 |
|
||||
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| macOS | **微信 4.1.13 系列**(推荐 [4.1.13.8](https://github.com/zsbai/wechat-versions/releases/tag/4.1.13.8))<br>备选 [4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 支持 Apple Silicon(M 系列)和 Intel Mac 自动获取数据库密钥。首次连接时,请按照 TraceMemo 页面提示完成系统授权及微信登录操作。 |
|
||||
| Windows | **微信 4.x**(推荐 [4.1.9.57](https://github.com/iibob/wechat-win-archive/releases/tag/v4.1.9.57)) | 支持自动获取数据库密钥。首次使用时请确认微信数据目录正确,无需处理 macOS 的 SIP 设置。 |
|
||||
|
||||
### macOS 用户
|
||||
|
||||
TraceMemo 已支持两种 Mac 架构:
|
||||
|
||||
- **Apple Silicon(M1 / M2 / M3 / M4 等)**
|
||||
- **Intel Mac(x64)**
|
||||
|
||||
两种架构均支持自动获取微信数据库密钥,TraceMemo 会根据当前 Mac 自动选择对应的连接方式。
|
||||
|
||||
Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取密钥时,请让微信停留在登录页面,并按照 TraceMemo 中显示的步骤操作。
|
||||
|
||||
> 不同 Mac 架构的连接流程可能略有区别,请始终以应用内「第一次使用」页面显示的提示为准。
|
||||
|
||||
### 关于微信版本
|
||||
|
||||
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
|
||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||
@@ -22,18 +39,20 @@
|
||||
|
||||
## 2. 安装并启动
|
||||
|
||||
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
|
||||
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载。
|
||||
|
||||
### Windows
|
||||
|
||||
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。
|
||||
1. 从 Releases 下载 Windows x64 的 `tracememo-<版本号>-setup.exe` 安装包。
|
||||
2. 双击安装包,按向导完成安装。
|
||||
3. 启动 TraceMemo。
|
||||
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
|
||||
|
||||
### macOS
|
||||
|
||||
1. 下载 Apple Silicon(M 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。
|
||||
1. 从 Releases 下载与你 Mac 处理器架构匹配的 `.dmg`:
|
||||
- Apple Silicon(M 系列、`arm64`):`tracememo-<版本号>-arm64.dmg`
|
||||
- Intel(`x64`):`tracememo-<版本号>-x64.dmg`
|
||||
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,可在终端执行:
|
||||
@@ -42,7 +61,16 @@
|
||||
xattr -cr "/Applications/TraceMemo.app"
|
||||
```
|
||||
|
||||
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
5. 先让微信停在**未登录窗口**(如果微信已经登录,请退出当前账号,**只关闭窗口不算**),再启动 TraceMemo。
|
||||
- 连接时会按提示自动获取数据库密钥;应用会根据当前架构进入对应流程,按连接页面显示的授权要求操作即可。
|
||||
- **等第五步 明确提示可以登录后,再回到微信点击登录。** 在此之前不要先点登录,否则这次密钥获取会失败,需要重新开始。
|
||||
- 只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
|
||||
> **注意事项:取密钥时提示监听超时(`CAPTURE_TIMEOUT`,或相关失败)**
|
||||
>
|
||||
> 这说明「先让微信停在未登录窗口」这一步操作有误——常见原因是微信当时已经登录,或者还没等连接页面提示就先点了登录
|
||||
>
|
||||
> 请先退出微信账号、回到**未登录窗口**,然后按上面的第 5 步**重新来一遍**(等连接页面明确提示可以登录后,再回到微信点击登录)。
|
||||
|
||||
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
|
||||
|
||||
@@ -106,7 +134,6 @@
|
||||
- [生成群聊日报或总结](./report.md)
|
||||
- [转写微信语音](./voice.md)
|
||||
- [导出聊天档案](./export.md)
|
||||
- [可选开启防撤回](./recall-protection.md)
|
||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||
|
||||
@@ -142,7 +169,6 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
|
||||
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
||||
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
||||
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
||||
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
|
||||
|
||||
完整边界见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Intel Mac 首次获取密钥
|
||||
|
||||
请先备份微信本地数据。密钥获取和数据库验证都在本机完成,不要把密钥、日志或数据库发给他人。
|
||||
|
||||
## 开始使用
|
||||
|
||||
1. 启动 TraceMemo,在首次连接页面点击“重新检查环境”。
|
||||
2. 如果页面显示需要准备连接环境,点击“准备环境”,按页面提示完成。
|
||||
3. 打开微信,让微信停在未登录页面,不要先点击登录。
|
||||
4. 回到 TraceMemo,选择微信账号,点击“开始获取密钥”。
|
||||
5. 等待页面提示可以登录后,立即回到微信点击登录。
|
||||
6. 等待 TraceMemo 显示“获取成功,验证数据库连接”。
|
||||
|
||||
获取成功后,请保存密钥。如果页面提示需要重新准备,按页面的“查看说明”操作,不要连续重试。
|
||||
|
||||
## 注意
|
||||
|
||||
- 密钥只保存在本机的安全存储中。
|
||||
- 请不要删除微信本地数据目录。
|
||||
@@ -13,7 +13,30 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
|
||||
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
|
||||
|
||||
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。
|
||||
同步在后台运行,**期间仍然可以正常提问和分析**,不会被禁用。索引还没追完时,答案会基于当前已经可用的部分给出,并在界面标注覆盖范围。
|
||||
|
||||
知识库卡片同时显示两组互相独立的信息:
|
||||
|
||||
- **规模**:已索引消息、知识片段、磁盘占用——说明索引有多大;
|
||||
- **状态与本轮进度**:说明索引现在处于什么状态、这一轮同步在做什么(扫了多少、真正新增了多少、处理到第几个会话)。
|
||||
|
||||
`最新索引` 只表示索引已经覆盖到聊天记录的哪个时间点,**不等于**整库已经建完;进度里的计数是**本轮**的数字,不是全部历史的总数。
|
||||
|
||||
### 状态怎么读
|
||||
|
||||
| 状态 | 含义 |
|
||||
| ---- | ---- |
|
||||
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||
|
||||
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
|
||||
|
||||
### 取消和继续
|
||||
|
||||
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
|
||||
|
||||
## 账号隔离
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
||||
|
||||
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
||||
|
||||
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
|
||||
|
||||
## 什么时候会请求外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
||||
@@ -58,4 +56,3 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
|
||||
- 对需要外发的 AI 功能逐项确认 Provider;
|
||||
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
|
||||
- 在共享电脑上退出应用并保护系统账户。
|
||||
- 在开启防撤回前确认你接受其数据库写入、性能和清理边界,并先用微信官方方式备份重要数据。
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# 防撤回
|
||||
|
||||
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
||||
|
||||
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
||||
|
||||
## 如何开启
|
||||
|
||||
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
||||
2. 打开“设置 → 防撤回”。
|
||||
3. 阅读性能和数据提示后,开启“防撤回”。
|
||||
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
||||
|
||||
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
||||
|
||||
## 当前能做什么
|
||||
|
||||
- 监听应用能够识别到的后续撤回变化;
|
||||
- 在本地保留必要的消息和撤回关系;
|
||||
- 将已识别的原消息与撤回状态一起显示在档案中;
|
||||
- 按微信账号隔离 TraceMemo 保存的恢复记录。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
||||
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
||||
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
||||
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
||||
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
||||
|
||||
## 数据写入与关闭边界
|
||||
|
||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
||||
|
||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
||||
|
||||
完整的数据边界见[数据、隐私与安全](./privacy.md)。
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
典型场景包括:
|
||||
|
||||
- 整理今天工作群的讨论重点;
|
||||
- 回顾昨天错过的决定和资源;
|
||||
- 整理今日工作群的讨论重点;
|
||||
- 回顾昨日错过的决定和资源;
|
||||
- 汇总近 7 天的项目进展、待办和未解决问题;
|
||||
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
|
||||
|
||||
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
|
||||
2. 选择时间范围:今天、昨天或近 7 天。
|
||||
2. 选择时间范围:今日、昨日或近 7 天。
|
||||
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
|
||||
4. 选择报告模板/内容模式并开始生成。
|
||||
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
|
||||
@@ -33,10 +33,22 @@
|
||||
|
||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||
|
||||
## 定时日报
|
||||
|
||||
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
|
||||
|
||||
```text
|
||||
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
|
||||
```
|
||||
|
||||
生成和发送是两个阶段。当前微信发送能力不可用、未绑定或发送失败时,报告仍会保存,PNG 和执行记录也会保留;这类结果会显示为“已生成,但未发送”或“已生成,发送失败”。
|
||||
|
||||
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
|
||||
|
||||
## 让报告更可靠
|
||||
|
||||
- 先选正确的群和时间范围;
|
||||
- 不确定时先只选择文字消息;
|
||||
- 群太活跃时分成“今天”和“近 7 天”两次生成;
|
||||
- 群太活跃时分成“今日”和“近 7 天”两次生成;
|
||||
- 看到待办和结论后回到原消息核对上下文;
|
||||
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
### macOS
|
||||
|
||||
- 确认下载的构建与 Mac 处理器匹配:Apple Silicon(M 系列)用 `arm64`,Intel 用 `x64`。
|
||||
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
|
||||
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
|
||||
|
||||
@@ -25,10 +26,13 @@
|
||||
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
|
||||
3. 微信是否处于页面要求的登录/退出状态;
|
||||
4. macOS 是否完成页面要求的授权;
|
||||
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。
|
||||
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题;
|
||||
6. macOS 上 Apple Silicon 与 Intel 使用不同的连接流程,按连接页面提示操作;两者都可以自动获取数据库密钥。
|
||||
|
||||
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
|
||||
|
||||
仍然失败时,记录 **macOS 版本、CPU 架构(Apple Silicon / Intel)、微信版本、TraceMemo 版本和页面错误提示**后 扫码 README 文档二维码进群提交消息, 或者提交 Issue。
|
||||
|
||||
## 连接成功但没有联系人或消息
|
||||
|
||||
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
|
||||
@@ -88,7 +92,3 @@ Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
|
||||
5. 需要总结或自然语言理解时,AI Provider 是否可用。
|
||||
|
||||
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
|
||||
|
||||
## 防撤回没有保留消息
|
||||
|
||||
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
appId: com.tracememo.app
|
||||
productName: TraceMemo
|
||||
afterPack: scripts/after-pack.cjs
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- out
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
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:
|
||||
# Keep the updater provider in every packaged Windows app. electron-builder also
|
||||
# regenerates this file during publish, using the same release configuration.
|
||||
- from: build/app-update.yml
|
||||
to: app-update.yml
|
||||
# Keep only resources required by the Windows x64 runtime.
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
- icon.png
|
||||
- daily_report_templates.html
|
||||
- mobile_daily_report.html
|
||||
- mobile_daily_report_v1.html
|
||||
- mobile_daily_report_v2.html
|
||||
- connectors/wechat/win32-x64/**
|
||||
- key/win32/x64/**
|
||||
- runtime/win32/**
|
||||
- wcdb/win32/x64/**
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
- '**/*'
|
||||
win:
|
||||
icon: icon.ico
|
||||
executableName: TraceMemo
|
||||
target:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
npmRebuild: false
|
||||
electronLanguages:
|
||||
- zh-CN
|
||||
- en-US
|
||||
publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: TraceMemo
|
||||
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||
releaseType: draft
|
||||
@@ -20,21 +20,20 @@ asarUnpack:
|
||||
- node_modules/sherpa-onnx-node/**
|
||||
- node_modules/sherpa-onnx-*/**
|
||||
extraResources:
|
||||
# Keep the updater provider in every packaged macOS app. electron-builder also
|
||||
# regenerates this file during publish, using the same release configuration.
|
||||
- from: build/app-update.yml
|
||||
to: app-update.yml
|
||||
# Includes the optional WeChat connector binary for the target platform.
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- '!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 using
|
||||
# TraceMemo as the product/shortcut name.
|
||||
executableName: electron
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
@@ -44,11 +43,11 @@ nsis:
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
icon: icon.icns
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
# The bundled WCDB bridge still uses Electron as its internal executable
|
||||
# compatibility name; the public product and bundle identity are TraceMemo.
|
||||
CFBundleName: Electron
|
||||
CFBundleDisplayName: TraceMemo
|
||||
NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
@@ -71,5 +70,7 @@ npmRebuild: false
|
||||
publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: WechatExplorer
|
||||
releaseType: release
|
||||
repo: TraceMemo
|
||||
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||
releaseType: draft
|
||||
|
||||
@@ -8,6 +8,8 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
reportTemplateTest: resolve('src/main/report-template-test-entry.ts'),
|
||||
queryAgentPoc: resolve('src/main/query-agent-poc-entry.ts'),
|
||||
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
|
||||
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
|
||||
},
|
||||
|
||||
@@ -24,6 +24,12 @@ export default defineConfig(
|
||||
'react-refresh': eslintPluginReactRefresh
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
endOfLine: 'auto'
|
||||
}
|
||||
],
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
...eslintPluginReactRefresh.configs.vite.rules
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# TraceMemo 日报模板示例
|
||||
|
||||
此目录是可安装的最小日报模板源文件,使用全部虚构数据进行预览。运行 `node scripts/build-report-template-example.cjs` 会生成 `examples/report-template-basic.zip`。
|
||||
|
||||
模板只能调整已有日报模块的 HTML/CSS 排版。作者不能加入 JavaScript、事件属性、外部网络资源、嵌套页面、数据库访问、AI Prompt 或新的业务分析。
|
||||
|
||||
占位符分为三类:普通文本(会被 HTML 转义)、应用生成的 HTML 片段(只能作为元素内容使用)、受限样式类(只能放进 `class` 属性,并由应用输出合法 token)。`*_MORE_NOTE` 是 HTML 片段,不是样式类。
|
||||
|
||||
允许的标签和属性由 `src/shared/report-template-package.ts` 统一定义;图片资源只能是包内 `assets/` 下的 PNG/JPEG/WebP。缺少可选模块时应用输出空字符串和 `*_EMPTY_CLASS`,模板应允许该模块隐藏。
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"protocolVersion": "1.0",
|
||||
"kind": "daily-report",
|
||||
"id": "community.github.example.basic-feed",
|
||||
"name": "基础信息流",
|
||||
"author": { "name": "example" },
|
||||
"templateVersion": "1.0.0",
|
||||
"interfaceVersion": "1",
|
||||
"entry": "template.html",
|
||||
"preview": "preview.png",
|
||||
"capture": { "width": 430, "maxWidth": 430, "maxHeight": 20000 },
|
||||
"license": { "spdx": "MIT" },
|
||||
"platform": "mobile"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{REPORT_TITLE}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; width: 430px; background: #f3f5f7; color: #18202a; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.report { padding: 18px 14px 32px; }
|
||||
.hero, .section { margin-top: 10px; padding: 16px; border-radius: 14px; background: #fff; }
|
||||
.hero { margin-top: 0; border-top: 4px solid #1769aa; }
|
||||
h1 { margin: 0 0 8px; font-size: 23px; }
|
||||
.meta, .muted { color: #64748b; font-size: 12px; line-height: 1.5; }
|
||||
.section-title { margin: 0 0 8px; font-size: 14px; }
|
||||
.topics-grid, .important-list { display: grid; gap: 8px; }
|
||||
.topic-card, .important-card { padding: 10px; border: 1px solid #dbe4ee; border-radius: 10px; }
|
||||
.empty-section { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="report {{REPORT_MODE_CLASS}}">
|
||||
<section class="hero {{REPORT_MODE_CLASS}}">
|
||||
<h1>{{REPORT_TITLE}}</h1>
|
||||
<div class="meta">{{REPORT_DATE}} · {{DATE_RANGE}}</div>
|
||||
<p>{{HERO_SUMMARY}}</p>
|
||||
<div class="muted">{{HERO_STATUS_LINE}}</div>
|
||||
</section>
|
||||
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||
<h2 class="section-title">今日话题</h2>
|
||||
<div class="topics-grid">{{TOPIC_CARDS}}</div>
|
||||
{{TOPICS_MORE_NOTE}}
|
||||
</section>
|
||||
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||
<h2 class="section-title">重要消息</h2>
|
||||
<div class="important-list">{{IMPORTANT_MESSAGES}}</div>
|
||||
</section>
|
||||
<footer class="muted">{{FOOTER_NOTE}}</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "tracememo",
|
||||
"version": "2.2.2",
|
||||
"version": "2.4.0",
|
||||
"packageManager": "pnpm@7.33.7",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"wechat chat",
|
||||
@@ -11,6 +11,7 @@
|
||||
"windows微信",
|
||||
"微信聊天记录",
|
||||
"微信聊天记录搜索",
|
||||
"微信群聊总结",
|
||||
"微信AI",
|
||||
"微信机器人",
|
||||
"AI聊天搜索",
|
||||
@@ -34,8 +35,16 @@
|
||||
"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:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
||||
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
|
||||
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
|
||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev",
|
||||
"poc:query-agent": "electron-vite build && node scripts/run-query-agent-poc.cjs",
|
||||
"poc:query-agent:run": "node scripts/run-query-agent-poc.cjs",
|
||||
"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",
|
||||
@@ -48,29 +57,48 @@
|
||||
"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:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64",
|
||||
"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",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
|
||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
||||
"release": "npm run release:mac && npm run release:win",
|
||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||
"release:mac": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64,x64 && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
|
||||
"release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||
"release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env EP_PRE_RELEASE=true npm run release",
|
||||
"release:stable": "npm run release",
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@radix-ui/react-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",
|
||||
"css-tree": "^3.0.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
@@ -78,9 +106,13 @@
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"parse5": "^8.0.0",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sherpa-onnx-node": "1.13.3",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"unzipper": "^0.12.0",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -101,6 +133,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",
|
||||
@@ -109,10 +142,13 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 338 KiB |
@@ -0,0 +1 @@
|
||||
f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { chmodSync, existsSync, renameSync } = require('node:fs')
|
||||
const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const asar = require('@electron/asar')
|
||||
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||
|
||||
const COMPATIBILITY_NAME = 'Electron'
|
||||
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
|
||||
const REQUIRED_RUNTIME_PACKAGES = [
|
||||
'@electron-toolkit/preload',
|
||||
'@electron-toolkit/utils',
|
||||
@@ -84,6 +83,43 @@ function normalizeBuilderArch(arch) {
|
||||
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
||||
}
|
||||
|
||||
/**
|
||||
* A foreign-architecture binary only fails once the user touches the feature
|
||||
* that needs it, so verify the ones whose filename is shared across
|
||||
* architectures (ffmpeg-static keeps a single "ffmpeg" per platform) and fail
|
||||
* the build instead of shipping a broken bundle.
|
||||
*/
|
||||
function validateRuntimeBinaryArchitecture(filePath, platform, arch, label) {
|
||||
if (platform !== 'darwin' && platform !== 'win32') return
|
||||
if (arch === 'universal') return
|
||||
const architectures = readBinaryArchitectures(filePath)
|
||||
if (!architectures.length || architectures.includes(arch)) return
|
||||
throw new Error(
|
||||
`${label} is ${architectures.join('/')} but this bundle targets ${arch}: ${filePath}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Intel Mac key helper is an x86_64 executable that only the x64 (or
|
||||
* universal) macOS bundle can run. Every other target — Apple Silicon macOS,
|
||||
* Windows, Linux — would otherwise ship a ~34MB binary it can never execute,
|
||||
* so it is dropped from those bundles. x64/universal builds fail fast instead
|
||||
* of silently shipping an Intel Mac app that cannot read keys.
|
||||
*/
|
||||
function pruneIntelMacKeyTool(runtimeResources, platform, arch) {
|
||||
const keyToolDirectory = path.join(runtimeResources, 'resources', 'macos-key-tool')
|
||||
const usable = platform === 'darwin' && (arch === 'x64' || arch === 'universal')
|
||||
if (!usable) {
|
||||
rmSync(keyToolDirectory, { recursive: true, force: true })
|
||||
return null
|
||||
}
|
||||
const helperPath = path.join(keyToolDirectory, 'intel_mac_key_helper')
|
||||
if (!existsSync(helperPath)) {
|
||||
throw new Error(`Missing Intel Mac key helper in a ${arch} bundle: ${helperPath}`)
|
||||
}
|
||||
return helperPath
|
||||
}
|
||||
|
||||
function validateAsarRuntimeDependencies(runtimeResources) {
|
||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||
@@ -101,10 +137,6 @@ function validateAsarRuntimeDependencies(runtimeResources) {
|
||||
)
|
||||
}
|
||||
}
|
||||
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)) {
|
||||
@@ -113,17 +145,87 @@ function validateReaderSkillRuntime(runtimeResources) {
|
||||
return skillPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Native runtime packages are published once per platform-arch pair, and pnpm
|
||||
* installs all of them, so every bundle ends up carrying the native libraries
|
||||
* of every platform (measured: ~129MB of speech models plus ~16MB of koffi).
|
||||
* The loaders pick their package from process.platform/arch, so the siblings
|
||||
* are dead weight — drop them.
|
||||
*/
|
||||
const NATIVE_RUNTIME_PACKAGES = [
|
||||
{
|
||||
modules: [],
|
||||
prefix: 'sherpa-onnx',
|
||||
platformName: (platform) => (platform === 'win32' ? 'win' : platform)
|
||||
},
|
||||
{
|
||||
modules: ['@koromix'],
|
||||
prefix: 'koffi',
|
||||
platformName: (platform) => platform
|
||||
}
|
||||
]
|
||||
|
||||
function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
|
||||
if (arch === 'universal') return []
|
||||
const unpackedRoot = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules')
|
||||
if (!existsSync(unpackedRoot)) return []
|
||||
const removed = []
|
||||
for (const runtime of NATIVE_RUNTIME_PACKAGES) {
|
||||
const modulesRoot = path.join(unpackedRoot, ...runtime.modules)
|
||||
if (!existsSync(modulesRoot)) continue
|
||||
const expected = `${runtime.prefix}-${runtime.platformName(platform)}-${arch}`
|
||||
const foreign = new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
|
||||
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
|
||||
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
|
||||
removed.push(runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled native directories under resources/connectors are named
|
||||
* "<platform>-<arch>". Cross-building both macOS architectures leaves both on
|
||||
* disk, but a bundle can only execute its own, so drop the foreign ones
|
||||
* instead of shipping every connector twice.
|
||||
*/
|
||||
function pruneForeignArchConnectors(runtimeResources, platform, arch) {
|
||||
if (arch === 'universal') return []
|
||||
const connectorsRoot = path.join(runtimeResources, 'resources', 'connectors')
|
||||
if (!existsSync(connectorsRoot)) return []
|
||||
const expected = `${platform}-${arch}`
|
||||
const removed = []
|
||||
for (const packageEntry of readdirSync(connectorsRoot, { withFileTypes: true })) {
|
||||
if (!packageEntry.isDirectory()) continue
|
||||
const packageRoot = path.join(connectorsRoot, packageEntry.name)
|
||||
for (const targetEntry of readdirSync(packageRoot, { withFileTypes: true })) {
|
||||
if (!targetEntry.isDirectory() || targetEntry.name === expected) continue
|
||||
if (!/^[a-z0-9]+-(arm64|x64|ia32)$/.test(targetEntry.name)) continue
|
||||
rmSync(path.join(packageRoot, targetEntry.name), { recursive: true, force: true })
|
||||
removed.push(`${packageEntry.name}/${targetEntry.name}`)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const runtimeResources = getRuntimeResources(context)
|
||||
const arch = normalizeBuilderArch(context.arch)
|
||||
validateAsarRuntimeDependencies(runtimeResources)
|
||||
validateReaderSkillRuntime(runtimeResources)
|
||||
validateSilkWasmRuntime(runtimeResources)
|
||||
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
||||
validateSherpaRuntime(
|
||||
runtimeResources,
|
||||
validateRuntimeBinaryArchitecture(
|
||||
ffmpegPath,
|
||||
context.electronPlatformName,
|
||||
normalizeBuilderArch(context.arch)
|
||||
arch,
|
||||
'Bundled ffmpeg'
|
||||
)
|
||||
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
|
||||
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
||||
@@ -148,47 +250,6 @@ exports.default = async function afterPack(context) {
|
||||
return
|
||||
}
|
||||
|
||||
if (context.electronPlatformName !== 'darwin') return
|
||||
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
const appPath = path.join(context.appOutDir, `${productName}.app`)
|
||||
const contentsPath = path.join(appPath, 'Contents')
|
||||
const sourceExecutable = path.join(contentsPath, 'MacOS', productName)
|
||||
const targetExecutable = path.join(contentsPath, 'MacOS', COMPATIBILITY_NAME)
|
||||
|
||||
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
|
||||
if (!existsSync(targetExecutable)) {
|
||||
throw new Error(`Missing Electron main executable: ${sourceExecutable}`)
|
||||
}
|
||||
|
||||
const appPlistPath = path.join(contentsPath, 'Info.plist')
|
||||
setPlistValue(appPlistPath, 'CFBundleExecutable', COMPATIBILITY_NAME)
|
||||
setPlistValue(appPlistPath, 'CFBundleName', COMPATIBILITY_NAME)
|
||||
|
||||
const frameworksPath = path.join(contentsPath, 'Frameworks')
|
||||
|
||||
for (const suffix of HELPER_SUFFIXES) {
|
||||
const sourceName = `${productName} Helper${suffix}`
|
||||
const targetName = `${COMPATIBILITY_NAME} Helper${suffix}`
|
||||
const sourceBundle = path.join(frameworksPath, `${sourceName}.app`)
|
||||
const targetBundle = path.join(frameworksPath, `${targetName}.app`)
|
||||
|
||||
if (existsSync(sourceBundle)) renameSync(sourceBundle, targetBundle)
|
||||
if (!existsSync(targetBundle)) {
|
||||
throw new Error(`Missing Electron helper bundle: ${sourceBundle}`)
|
||||
}
|
||||
|
||||
const sourceExecutable = path.join(targetBundle, 'Contents', 'MacOS', sourceName)
|
||||
const targetExecutable = path.join(targetBundle, 'Contents', 'MacOS', targetName)
|
||||
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
|
||||
if (!existsSync(targetExecutable)) {
|
||||
throw new Error(`Missing Electron helper executable: ${sourceExecutable}`)
|
||||
}
|
||||
|
||||
const plistPath = path.join(targetBundle, 'Contents', 'Info.plist')
|
||||
setPlistValue(plistPath, 'CFBundleExecutable', targetName)
|
||||
setPlistValue(plistPath, 'CFBundleName', targetName)
|
||||
}
|
||||
}
|
||||
|
||||
exports.getRuntimeResources = getRuntimeResources
|
||||
@@ -197,3 +258,7 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
|
||||
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
||||
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
|
||||
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
||||
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
||||
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('node:fs')
|
||||
|
||||
const MACHO_MAGIC_32 = 0xfeedface
|
||||
const MACHO_MAGIC_64 = 0xfeedfacf
|
||||
const FAT_MAGIC = 0xcafebabe
|
||||
const FAT_MAGIC_64 = 0xcafebabf
|
||||
const PE_SIGNATURE = 0x00004550
|
||||
const PE_MACHINE_X64 = 0x8664
|
||||
const PE_MACHINE_ARM64 = 0xaa64
|
||||
|
||||
const CPU_TYPE_IA32 = 0x00000007
|
||||
const CPU_TYPE_X86_64 = 0x01000007
|
||||
const CPU_TYPE_ARM64 = 0x0100000c
|
||||
|
||||
/** Only the headers are needed; native binaries can be tens of megabytes. */
|
||||
const HEADER_BYTES = 64 * 1024
|
||||
|
||||
function readHeader(filePath) {
|
||||
const descriptor = fs.openSync(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEADER_BYTES)
|
||||
const bytesRead = fs.readSync(descriptor, buffer, 0, HEADER_BYTES, 0)
|
||||
return buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMachoCpuType(cpuType) {
|
||||
if (cpuType === CPU_TYPE_X86_64) return 'x64'
|
||||
if (cpuType === CPU_TYPE_ARM64) return 'arm64'
|
||||
if (cpuType === CPU_TYPE_IA32) return 'ia32'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every architecture contained in a Mach-O or PE binary, as
|
||||
* electron-builder arch names ("x64", "arm64"). Universal binaries report both.
|
||||
* Returns an empty array for anything that is not a native executable (scripts,
|
||||
* wasm), so callers can treat "unknown" separately from "wrong architecture".
|
||||
*/
|
||||
function readBinaryArchitectures(filePath) {
|
||||
let buffer
|
||||
try {
|
||||
buffer = readHeader(filePath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (buffer.length < 8) return []
|
||||
|
||||
const fatMagic = buffer.readUInt32BE(0)
|
||||
if (fatMagic === FAT_MAGIC || fatMagic === FAT_MAGIC_64) {
|
||||
const entrySize = fatMagic === FAT_MAGIC_64 ? 32 : 20
|
||||
const count = Math.min(buffer.readUInt32BE(4), 32)
|
||||
const architectures = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const entryOffset = 8 + index * entrySize
|
||||
if (entryOffset + 4 > buffer.length) break
|
||||
const name = normalizeMachoCpuType(buffer.readUInt32BE(entryOffset))
|
||||
if (name && !architectures.includes(name)) architectures.push(name)
|
||||
}
|
||||
return architectures
|
||||
}
|
||||
|
||||
const thinMagic = buffer.readUInt32LE(0)
|
||||
if (thinMagic === MACHO_MAGIC_32 || thinMagic === MACHO_MAGIC_64) {
|
||||
const name = normalizeMachoCpuType(buffer.readUInt32LE(4))
|
||||
return name ? [name] : []
|
||||
}
|
||||
|
||||
if (buffer.readUInt16LE(0) === 0x5a4d) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (peOffset + 6 > buffer.length || buffer.readUInt32LE(peOffset) !== PE_SIGNATURE) return []
|
||||
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||
if (machine === PE_MACHINE_X64) return ['x64']
|
||||
if (machine === PE_MACHINE_ARM64) return ['arm64']
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
module.exports = { readBinaryArchitectures }
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { ZipArchive } = require('archiver')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const source = path.join(root, 'examples', 'report-template-basic')
|
||||
const outputPath = path.join(root, 'examples', 'report-template-basic.zip')
|
||||
const output = fs.createWriteStream(outputPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } })
|
||||
output.on('close', () => console.log(`wrote ${outputPath} (${archive.pointer()} bytes)`))
|
||||
archive.on('error', (error) => { throw error })
|
||||
archive.pipe(output)
|
||||
archive.file(path.join(source, 'manifest.json'), { name: 'manifest.json' })
|
||||
archive.file(path.join(source, 'template.html'), { name: 'template.html' })
|
||||
// 使用 1x1 PNG 作为虚构预览占位图,避免引入真实用户媒体。
|
||||
archive.append(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), { name: 'preview.png' })
|
||||
archive.finalize()
|
||||
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
/**
|
||||
* electron@43 的 npm 包不再声明 postinstall(它自己的 package.json 里 scripts 是空的),
|
||||
* 下载被改成「首次 require('electron') 时的懒加载」。后果是 `pnpm install` 之后
|
||||
* node_modules/electron 里只有一个空壳:dist/ 与 path.txt 都不在,而
|
||||
* package.json 里的 pnpm.onlyBuiltDependencies: ["electron"] 对着空气发号施令 ——
|
||||
* 上游没有脚本可跑,pnpm 自然什么也不做。新 clone 于是直到 `pnpm dev` 才炸。
|
||||
* 这里显式补上下载,并把它挂在 postinstall / predev 上。
|
||||
*/
|
||||
const MIRROR_FALLBACK = 'https://npmmirror.com/mirrors/electron/'
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
|
||||
function resolveElectronPackageRoot() {
|
||||
try {
|
||||
return path.dirname(require.resolve('electron/package.json'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* .npmrc 的 electron_mirror 只有在 npm/pnpm 执行脚本时才会变成 npm_config_* 环境变量;
|
||||
* 直接 `node node_modules/electron/install.js` 时读不到,于是会绕开镜像去打 GitHub。
|
||||
* 这里显式读出来当 ELECTRON_MIRROR 传下去。显式设置的环境变量优先。
|
||||
*/
|
||||
function resolveMirror() {
|
||||
if (process.env.ELECTRON_MIRROR) return process.env.ELECTRON_MIRROR
|
||||
try {
|
||||
const npmrc = fs.readFileSync(path.join(projectRoot, '.npmrc'), 'utf8')
|
||||
const match = npmrc.match(/^\s*electron_mirror\s*=\s*(\S+)\s*$/m)
|
||||
return match ? match[1] : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* path.txt 只是 electron 写下的相对路径,光有它不算装好 —— 指向的可执行文件
|
||||
* 必须真的存在,否则仍会在启动时报「Electron failed to install correctly」。
|
||||
*/
|
||||
function isElectronBinaryInstalled(packageRoot) {
|
||||
if (!packageRoot) return false
|
||||
try {
|
||||
const executable = fs.readFileSync(path.join(packageRoot, 'path.txt'), 'utf8').trim()
|
||||
return executable !== '' && fs.existsSync(path.join(packageRoot, 'dist', executable))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function runInstaller(packageRoot) {
|
||||
const installer = path.join(packageRoot, 'install.js')
|
||||
if (!fs.existsSync(installer)) {
|
||||
throw new Error(`[ensure-electron] missing ${installer}; run pnpm install first`)
|
||||
}
|
||||
const mirror = resolveMirror()
|
||||
const attempts = mirror
|
||||
? [{ label: mirror, env: { ELECTRON_MIRROR: mirror } }]
|
||||
: [{ label: 'default source', env: {} }]
|
||||
// 配的镜像本身不是 npmmirror 时,再兜一层:镜像挂掉时不至于完全没退路。
|
||||
if (!mirror.includes('npmmirror.com')) {
|
||||
attempts.push({ label: MIRROR_FALLBACK, env: { ELECTRON_MIRROR: MIRROR_FALLBACK } })
|
||||
}
|
||||
let lastError
|
||||
for (let index = 0; index < attempts.length; index += 1) {
|
||||
const attempt = attempts[index]
|
||||
console.log(`[ensure-electron] downloading from ${attempt.label}`)
|
||||
try {
|
||||
execFileSync(process.execPath, [installer], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...attempt.env }
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const next = attempts[index + 1]
|
||||
if (next) {
|
||||
console.warn(`[ensure-electron] download failed, retrying from ${next.label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function ensureElectronBinary() {
|
||||
const packageRoot = resolveElectronPackageRoot()
|
||||
if (isElectronBinaryInstalled(packageRoot)) return false
|
||||
console.log('[ensure-electron] Electron binary is missing; downloading it now')
|
||||
runInstaller(packageRoot)
|
||||
if (!isElectronBinaryInstalled(packageRoot)) {
|
||||
throw new Error('[ensure-electron] Electron binary is still missing after installing')
|
||||
}
|
||||
console.log('[ensure-electron] Electron binary is ready')
|
||||
return true
|
||||
}
|
||||
|
||||
if (require.main === module) ensureElectronBinary()
|
||||
|
||||
module.exports = { ensureElectronBinary, isElectronBinaryInstalled, resolveElectronPackageRoot }
|
||||
@@ -98,7 +98,7 @@ const activityLine = Array.from(document.querySelectorAll('.analytics > .card'))
|
||||
const values = {
|
||||
REPORT_TITLE: title,
|
||||
REPORT_DATE: reportDate,
|
||||
DATE_RANGE: '今天',
|
||||
DATE_RANGE: '今日',
|
||||
TIME_SPAN: statValues[2] || dateTimeRange,
|
||||
HERO_SUMMARY: overview,
|
||||
HERO_TAKEAWAY: '',
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||
|
||||
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
|
||||
|
||||
@@ -24,6 +25,31 @@ function readOption(name, fallback) {
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
|
||||
}
|
||||
|
||||
function ffmpegExecutableName(targetPlatform) {
|
||||
return targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg-static keeps a single binary per platform ("ffmpeg" everywhere except
|
||||
* Windows), so an arm64 and an x64 macOS checkout cannot coexist in
|
||||
* node_modules. Its installer also exits early whenever the file already
|
||||
* exists, so a binary left over from the other architecture would be packed
|
||||
* silently. Check the real architecture and drop the file when it differs, so
|
||||
* the caller re-downloads the requested one.
|
||||
*/
|
||||
function ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch) {
|
||||
if (!fs.existsSync(ffmpegPath)) return 'missing'
|
||||
const architectures = readBinaryArchitectures(ffmpegPath)
|
||||
if (architectures.includes(targetArch)) return 'match'
|
||||
console.log(
|
||||
`[prepare-electron-runtime] ffmpeg-static is ${
|
||||
architectures.join('/') || 'not a native binary'
|
||||
} but ${targetPlatform}-${targetArch} was requested; replacing it`
|
||||
)
|
||||
fs.rmSync(ffmpegPath, { force: true })
|
||||
return 'replaced'
|
||||
}
|
||||
|
||||
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
|
||||
let packageRoot = ''
|
||||
try {
|
||||
@@ -31,10 +57,11 @@ function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = pr
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const executable = ffmpegExecutableName(targetPlatform)
|
||||
const ffmpegPath = path.join(packageRoot, executable)
|
||||
const architectureState = ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch)
|
||||
|
||||
if (!fs.existsSync(ffmpegPath)) {
|
||||
if (architectureState !== 'match') {
|
||||
const installScript = path.join(packageRoot, 'install.js')
|
||||
console.log(
|
||||
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
|
||||
@@ -83,4 +110,6 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { ensureFfmpegArchitecture, ffmpegExecutableName, prepareFfmpegRuntime }
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
/*
|
||||
* 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 activeSend = ` 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));`
|
||||
const upstreamSend = ` 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));`
|
||||
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||
source = source
|
||||
.replace(
|
||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
|
||||
'var triggerX1Payload;\nvar triggerX0;'
|
||||
)
|
||||
.replace(activeSend, upstreamSend)
|
||||
.replace(
|
||||
' MMStartTask(triggerX0, activeTriggerX1Payload);',
|
||||
' MMStartTask(triggerX0, triggerX1Payload);'
|
||||
)
|
||||
.replace(
|
||||
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||
)
|
||||
.replace(
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
|
||||
}
|
||||
|
||||
function patchSendContextCapture(scriptPath, strict = true) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function isLikelySendContext(')) return
|
||||
const original = `function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
|
||||
if (triggerX1Payload) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerX0 = this.context.x0;
|
||||
triggerX1Payload = this.context.x1;
|
||||
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
|
||||
try {
|
||||
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
|
||||
var manager = readPointerIfReadable(candidateX0.add(0x18));
|
||||
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
|
||||
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
|
||||
return !manager.equals(ptr(0));
|
||||
} catch (e) {
|
||||
console.error("[debug] StartTask candidate inspect failed: " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
if (triggerX1Payload) return;
|
||||
var candidateX0 = this.context.x0;
|
||||
var candidateX1 = this.context.x1;
|
||||
if (!isLikelySendContext(candidateX0, candidateX1)) return;
|
||||
triggerX0 = candidateX0;
|
||||
triggerX1Payload = candidateX1;
|
||||
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
if (!source.includes(original)) {
|
||||
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
|
||||
return
|
||||
}
|
||||
source = source.replace(original, patched)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchVoiceAudioBuffer(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
|
||||
|
||||
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
|
||||
if (!source.includes(staticAllocation)) {
|
||||
throw new Error('无法定位 wechat_chatter 语音缓冲区')
|
||||
}
|
||||
source = source.replace(
|
||||
staticAllocation,
|
||||
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
|
||||
)
|
||||
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
|
||||
if (!source.includes(audioLengthMarker)) {
|
||||
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
|
||||
}
|
||||
source = source.replace(
|
||||
audioLengthMarker,
|
||||
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
|
||||
}
|
||||
|
||||
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 状态补丁')
|
||||
}
|
||||
|
||||
// Backport of wechat_chatter PR #36 by @Leslielu:
|
||||
// https://github.com/yincongcyincong/wechat_chatter/pull/36
|
||||
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
|
||||
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
|
||||
function patchCdnColdStart(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function resolveCdnManager()')) return
|
||||
|
||||
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
|
||||
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
|
||||
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
|
||||
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
|
||||
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
|
||||
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||
source = source.replace(initAddresses, patchedInitAddresses)
|
||||
|
||||
const downloadChunkEnd = `}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
const resolver = `}
|
||||
|
||||
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
|
||||
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
|
||||
function resolveCdnManager() {
|
||||
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
|
||||
return ptr(0);
|
||||
}
|
||||
try {
|
||||
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
|
||||
var strDefault = Memory.alloc(24);
|
||||
strDefault.writeUtf8String("default");
|
||||
strDefault.add(0x17).writeU8(7);
|
||||
|
||||
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
|
||||
var svc = getService(strDefault);
|
||||
if (!isReadablePointer(svc)) {
|
||||
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
|
||||
return ptr(0);
|
||||
}
|
||||
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
|
||||
var ctx = getCtx(svc);
|
||||
if (!isReadablePointer(ctx)) {
|
||||
console.error("[!] CdnManager getter 返回不可读: " + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
var mgr = readPointerIfReadable(ctx.add(0x40));
|
||||
if (!isReadablePointer(mgr)) {
|
||||
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
return mgr;
|
||||
} catch (e) {
|
||||
console.error("[!] resolveCdnManager 异常: " + e);
|
||||
return ptr(0);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCdnManagerX0() {
|
||||
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = uploadGlobalX0;
|
||||
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
var mgr = resolveCdnManager();
|
||||
if (!mgr.equals(ptr(0))) {
|
||||
uploadGlobalX0 = mgr;
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = mgr;
|
||||
}
|
||||
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
|
||||
}
|
||||
}
|
||||
return !uploadGlobalX0.equals(ptr(0));
|
||||
}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
|
||||
source = source.replace(downloadChunkEnd, resolver)
|
||||
|
||||
const declarations = 'var uploadImageAddr;\n'
|
||||
const patchedDeclarations =
|
||||
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
|
||||
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
|
||||
source = source.replace(declarations, patchedDeclarations)
|
||||
|
||||
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
|
||||
source = source.replace(uploadGuard, patchedUploadGuard)
|
||||
|
||||
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
|
||||
source = source.replace(voiceGuard, patchedVoiceGuard)
|
||||
|
||||
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = capturedUploadX0;
|
||||
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
|
||||
source = source.replace(uploadHook, patchedUploadHook)
|
||||
|
||||
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
|
||||
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
|
||||
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
|
||||
source = source.replace(downloadHook, patchedDownloadHook)
|
||||
|
||||
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0) {`
|
||||
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (!downloadGlobalX0) {`
|
||||
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
|
||||
source = source.replace(downloadGuard, patchedDownloadGuard)
|
||||
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
|
||||
}
|
||||
|
||||
function patchCdnColdStartConfig(configPath) {
|
||||
const source = fs.readFileSync(configPath, 'utf8')
|
||||
let config
|
||||
try {
|
||||
config = JSON.parse(source)
|
||||
} catch {
|
||||
throw new Error('4.1.11.53 版本配置不是有效 JSON')
|
||||
}
|
||||
config.cdnGetServiceAddr = '0x50a15d0'
|
||||
config.cdnManagerGetterAddr = '0x5259290'
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
|
||||
}
|
||||
|
||||
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, dynamic voice upload buffers,
|
||||
* CdnManager cold-start resolution, media hook backfill, 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)
|
||||
patchSendContextCapture(script)
|
||||
patchVoiceAudioBuffer(script)
|
||||
patchImageHookReadiness(script)
|
||||
patchCdnColdStartConfig(config)
|
||||
patchCdnColdStart(script)
|
||||
addModifiedWorkNotice(script)
|
||||
fs.chmodSync(executable, 0o755)
|
||||
console.log('[wechat-personal] 运行时准备完成')
|
||||
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const defaultRuntimeRoot = path.join(projectRoot, 'node_modules', 'sherpa-onnx-win-x64')
|
||||
const requiredFiles = ['package.json', 'sherpa-onnx.node']
|
||||
|
||||
function hasWindowsSherpaRuntime(runtimeRoot = defaultRuntimeRoot) {
|
||||
return requiredFiles.every((fileName) => fs.existsSync(path.join(runtimeRoot, fileName)))
|
||||
}
|
||||
|
||||
function ensureWindowsSherpaRuntime() {
|
||||
if (hasWindowsSherpaRuntime()) {
|
||||
console.log('[prepare-win-runtime] sherpa-onnx-win-x64 is ready')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[prepare-win-runtime] installing cross-platform optional dependencies')
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
['install', '--force', '--ignore-scripts'],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
if (!hasWindowsSherpaRuntime()) {
|
||||
throw new Error(`Missing Windows sherpa runtime: ${defaultRuntimeRoot}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) ensureWindowsSherpaRuntime()
|
||||
|
||||
module.exports = { hasWindowsSherpaRuntime, ensureWindowsSherpaRuntime }
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Query Agent POC 快速运行入口:直接执行已构建的 out/main/queryAgentPoc.js,不做任何构建。
|
||||
*
|
||||
* 与 `pnpm poc:query-agent` 的分工:
|
||||
* - poc:query-agent : 先 electron-vite build,再运行(代码改动后使用)
|
||||
* - poc:query-agent:run : 只运行现有构建产物(连续测试使用)
|
||||
*
|
||||
* 若构建产物不存在,给出明确提示;不会偷偷触发 full build,否则 fast-run 失去意义。
|
||||
*
|
||||
* 可选环境变量:
|
||||
* TRACEMEMO_POC_ELECTRON 指定 Electron 可执行文件(默认取 node_modules 中的 electron)
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const pocEntry = path.join(repoRoot, 'out', 'main', 'queryAgentPoc.js')
|
||||
|
||||
if (!fs.existsSync(pocEntry)) {
|
||||
process.stderr.write(
|
||||
[
|
||||
'',
|
||||
'[poc] POC build 不存在,请先运行:',
|
||||
' pnpm poc:query-agent "你的问题"',
|
||||
'',
|
||||
` 预期构建产物:${path.relative(repoRoot, pocEntry)}`,
|
||||
' (本入口有意不自动构建,以免失去快速运行的意义)',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 该入口的目标就是启动 Electron 主进程,因此必须清掉会让 Electron 退化成纯 Node 的标记。
|
||||
// 部分 IDE 集成终端会注入 ELECTRON_RUN_AS_NODE=1。
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
|
||||
// 在普通 Node 中 require('electron') 返回可执行文件路径。
|
||||
const electronBinary = env.TRACEMEMO_POC_ELECTRON || require('electron')
|
||||
|
||||
const result = spawnSync(electronBinary, [pocEntry, ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
cwd: repoRoot,
|
||||
env
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
process.stderr.write(`[poc] 启动 Electron 失败:${result.error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(typeof result.status === 'number' ? result.status : 1)
|
||||
@@ -7,8 +7,16 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setTestHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
return home
|
||||
}
|
||||
|
||||
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t)
|
||||
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
|
||||
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
|
||||
if err := SaveCredentials(old); err != nil {
|
||||
@@ -37,8 +45,7 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
home := setTestHome(t)
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -50,8 +57,7 @@ func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
setTestHome(t)
|
||||
legacyDir, err := LegacyAccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEEPSEEK_API_KEY: string
|
||||
readonly VITE_SCHEDULED_REPORT_DEBUG: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -9,17 +9,12 @@ export const LEGACY_MIGRATION_RESULT_FD_ENV = 'TRACEMEMO_LEGACY_MIGRATION_RESULT
|
||||
|
||||
// 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.
|
||||
// TraceMemo userData is installed before any consumers. The migration helper
|
||||
// temporarily uses the legacy identity; normal Windows builds use TraceMemo.
|
||||
const isLegacyMigrationHelper = process.env[LEGACY_MIGRATION_HELPER_ENV] === '1'
|
||||
const legacyRuntimeName = process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME
|
||||
const legacyRuntimeName = LEGACY_USER_DATA_NAME
|
||||
const runtimeName =
|
||||
process.platform === 'win32'
|
||||
? 'WeFlow'
|
||||
: isLegacyMigrationHelper
|
||||
? legacyRuntimeName
|
||||
: TRACE_MEMO_RUNTIME_NAME
|
||||
isLegacyMigrationHelper ? legacyRuntimeName : TRACE_MEMO_RUNTIME_NAME
|
||||
app.setName(runtimeName)
|
||||
|
||||
const isolatedUserData = process.env['WXE_USER_DATA']
|
||||
|
||||
@@ -329,6 +329,32 @@ body {
|
||||
color: var(--accent);
|
||||
}
|
||||
.timeline-month small { color: inherit; }
|
||||
.timeline-month-entry { display: grid; gap: 2px; }
|
||||
.timeline-days {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin: 0 0 4px 11px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.timeline-days[hidden] { display: none; }
|
||||
.timeline-day {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 5px 7px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.timeline-day:hover, .timeline-day.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.timeline-day small { color: inherit; }
|
||||
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
|
||||
.lazy-hint {
|
||||
width: min(100%, 820px);
|
||||
@@ -787,6 +813,23 @@ body {
|
||||
}
|
||||
.timeline-months { display: flex; gap: 6px; }
|
||||
.timeline-months[hidden] { display: none; }
|
||||
.timeline-month-entry { display: grid; gap: 2px; }
|
||||
.timeline-days {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
.timeline-days[hidden] { display: none; }
|
||||
.timeline-day {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 5px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-day:hover, .timeline-day.active { border-bottom-color: var(--accent); }
|
||||
.timeline-month {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
@@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => `
|
||||
let scrollLoadSuppressed = false
|
||||
let activeMonthUpdatePending = false
|
||||
let expandedTimelineYear = ''
|
||||
let expandedTimelineMonth = ''
|
||||
let selectedTimelineMonth = ''
|
||||
let selectedTimelineDate = ''
|
||||
const tabPositions = new Map()
|
||||
let lastScrollTop = 0
|
||||
let zoom = 1
|
||||
@@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => `
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
|
||||
}
|
||||
const dateKey = (message) => {
|
||||
const timestamp = Number(message.createTime || 0)
|
||||
if (!timestamp) return 'unknown'
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (Number.isNaN(date.getTime())) return 'unknown'
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
|
||||
}
|
||||
const kindOf = (message) => {
|
||||
const data = message.contentData || {}
|
||||
const shareType = String(data.typeVal || '')
|
||||
@@ -1317,6 +1370,15 @@ const renderExportScript = (name: string): string => `
|
||||
if (months) months.hidden = !expanded
|
||||
})
|
||||
}
|
||||
const setExpandedTimelineMonth = (month) => {
|
||||
expandedTimelineMonth = month || ''
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
const expanded = button.dataset.month === expandedTimelineMonth
|
||||
button.setAttribute('aria-expanded', String(expanded))
|
||||
const days = button.nextElementSibling
|
||||
if (days) days.hidden = !expanded
|
||||
})
|
||||
}
|
||||
const renderTimeline = () => {
|
||||
if (filtered.length === 0) {
|
||||
expandedTimelineYear = ''
|
||||
@@ -1327,18 +1389,36 @@ const renderExportScript = (name: string): string => `
|
||||
for (const message of filtered) {
|
||||
const key = monthKey(message)
|
||||
if (key === 'unknown') continue
|
||||
groups.set(key, (groups.get(key) || 0) + 1)
|
||||
const date = dateKey(message)
|
||||
const group = groups.get(key) || { total: 0, days: new Map() }
|
||||
group.total += 1
|
||||
if (date !== 'unknown') group.days.set(date, (group.days.get(date) || 0) + 1)
|
||||
groups.set(key, group)
|
||||
}
|
||||
const yearGroups = new Map()
|
||||
for (const [key, total] of groups) {
|
||||
for (const [key, group] of groups) {
|
||||
const parts = key.split('-')
|
||||
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
|
||||
yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total })
|
||||
yearGroups.get(parts[0]).push({
|
||||
key,
|
||||
month: Number(parts[1]),
|
||||
total: group.total,
|
||||
days: Array.from(group.days.entries())
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([date, total]) => ({ date, day: Number(date.slice(-2)), total }))
|
||||
})
|
||||
}
|
||||
const years = Array.from(yearGroups.keys())
|
||||
const years = Array.from(yearGroups.keys()).sort((left, right) => left.localeCompare(right))
|
||||
if (!years.includes(expandedTimelineYear)) {
|
||||
expandedTimelineYear = years[years.length - 1] || ''
|
||||
}
|
||||
for (const entries of yearGroups.values()) {
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
const allMonthKeys = Array.from(groups.keys()).sort((left, right) => left.localeCompare(right))
|
||||
if (!allMonthKeys.includes(expandedTimelineMonth)) {
|
||||
expandedTimelineMonth = allMonthKeys[allMonthKeys.length - 1] || ''
|
||||
}
|
||||
let html = ''
|
||||
for (const year of years) {
|
||||
const expanded = year === expandedTimelineYear
|
||||
@@ -1348,10 +1428,18 @@ const renderExportScript = (name: string): string => `
|
||||
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
|
||||
esc(year) + ' 年</button>' +
|
||||
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
|
||||
yearGroups.get(year).map((entry) =>
|
||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
|
||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
|
||||
).join('') + '</div></section>'
|
||||
yearGroups.get(year).map((entry) => {
|
||||
const monthExpanded = entry.key === expandedTimelineMonth
|
||||
return '<div class="timeline-month-entry">' +
|
||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) +
|
||||
'" aria-expanded="' + String(monthExpanded) + '">' +
|
||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>' +
|
||||
'<div class="timeline-days" data-days-for-month="' + esc(entry.key) + '"' +
|
||||
(monthExpanded ? '' : ' hidden') + '>' + entry.days.map((day) =>
|
||||
'<button class="timeline-day" type="button" data-date="' + esc(day.date) + '">' +
|
||||
'<span>' + esc(day.date.slice(5)) + '</span><small>' + day.total + '</small></button>'
|
||||
).join('') + '</div></div>'
|
||||
}).join('') + '</div></section>'
|
||||
}
|
||||
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
||||
}
|
||||
@@ -1377,16 +1465,27 @@ const renderExportScript = (name: string): string => `
|
||||
const activeMessage = atBottom
|
||||
? visible[visible.length - 1] || messages[messages.length - 1]
|
||||
: anchoredMessage || visible[0] || messages[0]
|
||||
const key = activeMessage && activeMessage.dataset.month
|
||||
const key = selectedTimelineMonth || (selectedTimelineDate
|
||||
? selectedTimelineDate.slice(0, 7)
|
||||
: '') ||
|
||||
(activeMessage && activeMessage.dataset.month)
|
||||
const activeIndex = activeMessage ? Number(activeMessage.dataset.index) : -1
|
||||
const activeDate =
|
||||
selectedTimelineDate ||
|
||||
(activeIndex >= 0 && filtered[activeIndex] ? dateKey(filtered[activeIndex]) : '')
|
||||
let activeButton
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
const active = button.dataset.month === key
|
||||
button.classList.toggle('active', active)
|
||||
if (active) activeButton = button
|
||||
})
|
||||
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.date === activeDate)
|
||||
})
|
||||
if (!activeButton) return
|
||||
const activeYear = key.split('-')[0]
|
||||
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
||||
if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key)
|
||||
const timelineBounds = timeline.getBoundingClientRect()
|
||||
const buttonBounds = activeButton.getBoundingClientRect()
|
||||
if (buttonBounds.top < timelineBounds.top) {
|
||||
@@ -1535,6 +1634,8 @@ const renderExportScript = (name: string): string => `
|
||||
)
|
||||
}
|
||||
const applyFilters = (restorePosition = false) => {
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = ''
|
||||
filtered = matchingMessages()
|
||||
renderTimeline()
|
||||
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
||||
@@ -1572,18 +1673,32 @@ const renderExportScript = (name: string): string => `
|
||||
target.classList.add('located')
|
||||
window.setTimeout(() => target.classList.remove('located'), 1600)
|
||||
}
|
||||
const jumpToMonth = (key) => {
|
||||
const index = filtered.findIndex((message) => monthKey(message) === key)
|
||||
const selectTimelineMonth = (key) => {
|
||||
if (!filtered.some((message) => monthKey(message) === key)) return
|
||||
selectedTimelineMonth = key
|
||||
selectedTimelineDate = ''
|
||||
setExpandedTimelineYear(key.split('-')[0])
|
||||
setExpandedTimelineMonth(key)
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.month === key)
|
||||
})
|
||||
}
|
||||
const jumpToDate = (key) => {
|
||||
const index = filtered.findIndex((message) => dateKey(message) === key)
|
||||
if (index < 0) return
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = key
|
||||
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
|
||||
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
||||
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
||||
renderWindow()
|
||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
||||
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
||||
setExpandedTimelineYear(key.split('-')[0])
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.month === key)
|
||||
const month = key.slice(0, 7)
|
||||
setExpandedTimelineYear(month.slice(0, 4))
|
||||
setExpandedTimelineMonth(month)
|
||||
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.date === key)
|
||||
})
|
||||
}
|
||||
const slideWindow = (direction) => {
|
||||
@@ -1620,6 +1735,10 @@ const renderExportScript = (name: string): string => `
|
||||
const nearTop = currentTop < 180
|
||||
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
||||
lastScrollTop = currentTop
|
||||
if (!scrollLoadSuppressed) {
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = ''
|
||||
}
|
||||
scheduleActiveMonthUpdate()
|
||||
if (scrollLoadSuppressed) return
|
||||
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
||||
@@ -1676,7 +1795,12 @@ const renderExportScript = (name: string): string => `
|
||||
return
|
||||
}
|
||||
const button = event.target.closest('[data-month]')
|
||||
if (button) jumpToMonth(button.dataset.month)
|
||||
if (button) {
|
||||
selectTimelineMonth(button.dataset.month)
|
||||
return
|
||||
}
|
||||
const dayButton = event.target.closest('[data-date]')
|
||||
if (dayButton) jumpToDate(dayButton.dataset.date)
|
||||
})
|
||||
|
||||
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
|
||||
|
||||
@@ -25,11 +25,12 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
|
||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||
import { imageFileQuality } from '../shared/image-quality'
|
||||
import { resolveMemberName } from '../shared/member-names'
|
||||
import { filesystemSafeName } from '../shared/contact-name'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const activeArchives = new Map<string, Archiver>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const safeFilePart = (value: string, fallback = '聊天档案'): string =>
|
||||
filesystemSafeName(value, fallback)
|
||||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||||
try {
|
||||
await fs.chmod(destination, 0o644)
|
||||
@@ -584,6 +585,19 @@ const detectAssetExtension = (buffer: Buffer): string | null => {
|
||||
return 'webp'
|
||||
return null
|
||||
}
|
||||
const htmlArchiveStickerResourceIsValid = async (
|
||||
outputDir: string,
|
||||
value?: string
|
||||
): Promise<boolean> => {
|
||||
const filePath = htmlArchiveResourcePath(outputDir, value)
|
||||
if (!filePath) return false
|
||||
try {
|
||||
return detectAssetExtension(await fs.readFile(filePath)) !== null
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async function readAvatarAsset(
|
||||
source: string
|
||||
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||
@@ -630,6 +644,7 @@ const kindOf = (message: Message): ExportMessageKind => {
|
||||
return 'text'
|
||||
}
|
||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const csvBom = '\uFEFF'
|
||||
|
||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||
if (format === 'html') return renderExportPage(name)
|
||||
@@ -637,7 +652,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||
if (format === 'markdown')
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||||
return [
|
||||
return csvBom + [
|
||||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||
...messages.map((m) =>
|
||||
[
|
||||
@@ -651,7 +666,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
.map(csv)
|
||||
.join(',')
|
||||
)
|
||||
].join('\n')
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
interface SingleExportOptions {
|
||||
@@ -668,6 +683,8 @@ interface AllExportManifestEntry {
|
||||
type: ExportTarget['type']
|
||||
folder: string
|
||||
messageCount: number
|
||||
status: 'completed' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const writeAllExportManifest = async (
|
||||
@@ -1073,7 +1090,10 @@ async function runSingleExport(
|
||||
}
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
? new VoiceService(
|
||||
chat.getChatDb()!.getWcdb4Client(),
|
||||
chat.getChatDb()!.getWcdb4Client().getAccountRoot()
|
||||
)
|
||||
: null
|
||||
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
||||
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
||||
@@ -1220,7 +1240,16 @@ async function runSingleExport(
|
||||
markResourceExists(voiceUrl)
|
||||
}
|
||||
message.voiceDataUrl = voiceUrl
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
// 读取 WAV 头中的采样率,避免按错误采样率计算时长(Silk 解码为 16kHz)
|
||||
const wavSampleRate =
|
||||
audioBuffer.length >= 44 ? audioBuffer.readUInt32LE(24) : 16000
|
||||
const wavChannels =
|
||||
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
|
||||
const pcmBytes = Math.max(0, audioBuffer.length - 44)
|
||||
message.voiceDuration = Math.max(
|
||||
1,
|
||||
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
|
||||
)
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
@@ -1329,12 +1358,28 @@ async function runSingleExport(
|
||||
const reusableImageQuality =
|
||||
previous?.exportMediaQuality === 'original' ||
|
||||
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
|
||||
let reusableMediaExists = false
|
||||
if (reusableMediaType && previous?.exportMediaUrl) {
|
||||
reusableMediaExists = await resourceExists(previous.exportMediaUrl)
|
||||
if (
|
||||
reusableMediaExists &&
|
||||
reusableMediaType === 'sticker' &&
|
||||
!(await htmlArchiveStickerResourceIsValid(outputDir, previous.exportMediaUrl))
|
||||
) {
|
||||
reusableMediaExists = false
|
||||
resourceExistence.set(previous.exportMediaUrl, Promise.resolve(false))
|
||||
delete previous.exportMediaUrl
|
||||
delete previous.exportMediaType
|
||||
delete previous.exportMediaName
|
||||
delete previous.exportMediaQuality
|
||||
}
|
||||
}
|
||||
if (
|
||||
reusableMediaType &&
|
||||
previous?.exportMediaUrl &&
|
||||
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
||||
(reusableMediaType !== 'image' || reusableImageQuality) &&
|
||||
(await resourceExists(previous.exportMediaUrl))
|
||||
reusableMediaExists
|
||||
) {
|
||||
message.exportMediaUrl = previous.exportMediaUrl
|
||||
message.exportMediaType = reusableMediaType
|
||||
@@ -1597,6 +1642,8 @@ async function runAllExport(
|
||||
let outputDir = ''
|
||||
const manifest: AllExportManifestEntry[] = []
|
||||
let totalMessages = 0
|
||||
let failedCount = 0
|
||||
const failureMessages: string[] = []
|
||||
try {
|
||||
const targets = [...(request.targets || [])].sort((left, right) =>
|
||||
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
||||
@@ -1629,80 +1676,121 @@ async function runAllExport(
|
||||
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
||||
const categoryDir = join(outputDir, categoryName)
|
||||
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
||||
await fs.mkdir(categoryDir, { recursive: true })
|
||||
const conversationOutputRoot =
|
||||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||||
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'reading',
|
||||
processed: 0,
|
||||
percent: basePercent,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
const folder = `${categoryName}/${folderName}`
|
||||
let result: ExportResult
|
||||
try {
|
||||
await fs.mkdir(categoryDir, { recursive: true })
|
||||
const conversationOutputRoot =
|
||||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||||
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'reading',
|
||||
processed: 0,
|
||||
percent: basePercent,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
|
||||
const result = await runSingleExport(
|
||||
{
|
||||
...request,
|
||||
targets: [target],
|
||||
outputName: target.name,
|
||||
zip: false
|
||||
},
|
||||
win,
|
||||
voiceRecognition,
|
||||
{
|
||||
outputRoot: conversationOutputRoot,
|
||||
outputFolderName: request.format === 'html' ? folderName : undefined,
|
||||
manageJob: false,
|
||||
selfInfo,
|
||||
sendProgress: (childProgress) => {
|
||||
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
||||
const percent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
||||
)
|
||||
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
||||
const now = Date.now()
|
||||
const progressKey = `${targetIndex}:${phase}:${percent}`
|
||||
const terminal = phase === 'failed' || phase === 'cancelled'
|
||||
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
||||
lastProgressKey = progressKey
|
||||
lastProgressAt = now
|
||||
send({
|
||||
...childProgress,
|
||||
jobId: request.jobId,
|
||||
phase,
|
||||
percent,
|
||||
outputPath: undefined,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
result = await runSingleExport(
|
||||
{
|
||||
...request,
|
||||
targets: [target],
|
||||
outputName: target.name,
|
||||
zip: false
|
||||
},
|
||||
win,
|
||||
voiceRecognition,
|
||||
{
|
||||
outputRoot: conversationOutputRoot,
|
||||
outputFolderName: request.format === 'html' ? folderName : undefined,
|
||||
manageJob: false,
|
||||
selfInfo,
|
||||
sendProgress: (childProgress) => {
|
||||
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
||||
const percent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
||||
)
|
||||
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
||||
const now = Date.now()
|
||||
const progressKey = `${targetIndex}:${phase}:${percent}`
|
||||
const terminal = phase === 'failed' || phase === 'cancelled'
|
||||
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
||||
lastProgressKey = progressKey
|
||||
lastProgressAt = now
|
||||
send({
|
||||
...childProgress,
|
||||
jobId: request.jobId,
|
||||
phase,
|
||||
percent,
|
||||
outputPath: undefined,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!result.success) {
|
||||
if (result.error === '已取消') throw new Error('已取消')
|
||||
throw new Error(`${target.name}:${result.error || '导出失败'}`)
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||
result = { success: false, error: message }
|
||||
}
|
||||
|
||||
const messageCount = result.messageCount || 0
|
||||
totalMessages += messageCount
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder: `${categoryName}/${folderName}`,
|
||||
messageCount
|
||||
})
|
||||
if (!result.success) {
|
||||
if (result.error === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const error = `${target.name}:${result.error || '导出失败'}`
|
||||
failedCount += 1
|
||||
failureMessages.push(error)
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder,
|
||||
messageCount: 0,
|
||||
status: 'failed',
|
||||
error: result.error || '导出失败'
|
||||
})
|
||||
const failedPercent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + 1) / targets.length) * 100)
|
||||
)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'failed',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: failedPercent,
|
||||
error,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
} else {
|
||||
const messageCount = result.messageCount || 0
|
||||
totalMessages += messageCount
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder,
|
||||
messageCount,
|
||||
status: 'completed'
|
||||
})
|
||||
}
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||||
}
|
||||
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
|
||||
const finalStatus = failedCount ? 'failed' : 'completed'
|
||||
const finalError = failedCount
|
||||
? `全部导出完成:成功 ${targets.length - failedCount} 个,失败 ${failedCount} 个。${failureMessages.join(';')}`
|
||||
: undefined
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, finalStatus, finalError)
|
||||
|
||||
let completedPath = outputDir
|
||||
if (request.zip) {
|
||||
@@ -1721,17 +1809,23 @@ async function runAllExport(
|
||||
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
phase: failedCount ? 'failed' : 'completed',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: 100,
|
||||
error: finalError,
|
||||
outputPath: completedPath,
|
||||
currentTargetIndex: targets.length,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: targets.at(-1)?.name,
|
||||
currentTargetType: targets.at(-1)?.type
|
||||
})
|
||||
return { success: true, outputPath: completedPath, messageCount: totalMessages }
|
||||
return {
|
||||
success: failedCount === 0,
|
||||
outputPath: completedPath,
|
||||
messageCount: totalMessages,
|
||||
...(finalError ? { error: finalError } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import crypto from 'node:crypto'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
@@ -15,6 +18,9 @@ import {
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
import { getReportTemplate } from '../shared/report-templates'
|
||||
import type { ReportTemplateRef } from '../shared/report-template-package'
|
||||
import { injectReportTemplateFragmentContract } from '../shared/report-template-fragment-contract'
|
||||
import { reportTemplateService, validateReportTemplateHtml } from './report-template-service'
|
||||
|
||||
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
||||
v1: 'mobile_daily_report_v1.html',
|
||||
@@ -33,6 +39,40 @@ const templatePath = (templateId?: string): string => {
|
||||
return found
|
||||
}
|
||||
|
||||
const resolveTemplateSelection = async (templateId?: string, templateRef?: ReportTemplateRef) => {
|
||||
if (!templateRef) {
|
||||
const definition = getReportTemplate(templateId)
|
||||
return {
|
||||
definition,
|
||||
entryPath: templatePath(templateId),
|
||||
captureMaxHeight: 20000,
|
||||
source: 'builtin' as const
|
||||
}
|
||||
}
|
||||
const installed = await reportTemplateService.resolve(templateRef)
|
||||
return {
|
||||
definition: {
|
||||
id: installed.id,
|
||||
order: 999,
|
||||
platform: 'default' as const,
|
||||
label: installed.name,
|
||||
name: installed.name,
|
||||
tagline: `${installed.author} · ${installed.version}`,
|
||||
fileLabel: installed.name,
|
||||
cssClass: `template-external-${installed.id.replace(/[^a-z0-9-]/gi, '-')}`,
|
||||
resourceFile: installed.entryPath,
|
||||
captureWidth: installed.capture.width,
|
||||
maxCaptureWidth: installed.capture.maxWidth
|
||||
},
|
||||
entryPath: installed.entryPath,
|
||||
captureMaxHeight: installed.capture.maxHeight,
|
||||
source: 'installed' as const
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTemplate = async (request: GroupReportExportRequest) =>
|
||||
resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
|
||||
const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -53,11 +93,13 @@ const hashName = (name: string): number => {
|
||||
return hash
|
||||
}
|
||||
|
||||
const fallbackAvatar = (name: string): string => {
|
||||
type RenderedAvatar = { source: string; fallback: boolean }
|
||||
|
||||
const fallbackAvatar = (name: string): RenderedAvatar => {
|
||||
const hue = hashName(name) % 360
|
||||
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||
return { source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, fallback: true }
|
||||
}
|
||||
|
||||
const imageMimeType = (contentType: string | null, source: string): string => {
|
||||
@@ -69,9 +111,9 @@ const imageMimeType = (contentType: string | null, source: string): string => {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
||||
const embedAvatar = async (source: string | undefined, name: string): Promise<RenderedAvatar> => {
|
||||
if (!source) return fallbackAvatar(name)
|
||||
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
||||
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return { source, fallback: false }
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
@@ -84,12 +126,12 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
|
||||
return { source: `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`, fallback: false }
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const buffer = await fs.readFile(localPath)
|
||||
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
|
||||
return { source: `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`, fallback: false }
|
||||
} catch (error) {
|
||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||
return fallbackAvatar(name)
|
||||
@@ -148,6 +190,58 @@ const heatClass = (heat: ReportHeat): string => {
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const addReportCsp = (html: string, allowFileImages = true): string =>
|
||||
html.replace(
|
||||
/<head(\s[^>]*)?>/i,
|
||||
(head) =>
|
||||
`${head}<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:${allowFileImages ? ' file:' : ''}; style-src 'unsafe-inline'; font-src 'none'; base-uri 'none'; form-action 'none'">`
|
||||
)
|
||||
|
||||
const inlineTemplateAssets = async (html: string, templateRoot: string): Promise<string> => {
|
||||
const assetDataUrl = async (relative: string): Promise<string | null> => {
|
||||
const assetPath = path.resolve(templateRoot, relative)
|
||||
if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) return null
|
||||
const extension = path.extname(assetPath).toLowerCase()
|
||||
const mime =
|
||||
extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||
const data = (await fs.readFile(assetPath)).toString('base64')
|
||||
return `data:${mime};base64,${data}`
|
||||
}
|
||||
let result = html
|
||||
for (const match of html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)) {
|
||||
const dataUrl = await assetDataUrl(match[2])
|
||||
if (dataUrl) result = result.replace(match[0], `src="${dataUrl}"`)
|
||||
}
|
||||
for (const match of html.matchAll(/\burl\(\s*(['"]?)(assets\/[A-Za-z0-9._/-]+)\1\s*\)/g)) {
|
||||
const dataUrl = await assetDataUrl(match[2])
|
||||
if (dataUrl) result = result.replace(match[0], `url("${dataUrl}")`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const recordBlockedTemplateRequest = (
|
||||
details: Electron.OnBeforeRequestListenerDetails,
|
||||
reason: string
|
||||
): void => {
|
||||
// 仅测试入口设置该路径,用于保留运行时拦截证据;生产默认不记录请求明细。
|
||||
const logPath = process.env.TRACEMEMO_TEMPLATE_SECURITY_LOG
|
||||
if (!logPath) return
|
||||
try {
|
||||
appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({
|
||||
url: details.url,
|
||||
method: details.method,
|
||||
resourceType: details.resourceType,
|
||||
reason
|
||||
})}\n`,
|
||||
'utf8'
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[GroupReport] failed to record template security block:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sectionMeta = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
@@ -170,7 +264,8 @@ const overflowNote = (
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const template = getReportTemplate(request.templateId)
|
||||
const resolvedTemplate = await resolveTemplate(request)
|
||||
const template = resolvedTemplate.definition
|
||||
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))
|
||||
@@ -181,24 +276,34 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||
|
||||
const avatars = new Map<string, string>()
|
||||
const avatars = new Map<string, RenderedAvatar>()
|
||||
await Promise.all(
|
||||
Array.from(avatarNames).map(async (name) => {
|
||||
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
||||
})
|
||||
)
|
||||
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||
const avatar = (name: string): RenderedAvatar => avatars.get(name) || fallbackAvatar(name)
|
||||
const renderAvatar = (
|
||||
name: string,
|
||||
role: 'hero' | 'message' | 'participant' | 'ranking',
|
||||
legacyClass = '',
|
||||
alt = ''
|
||||
): string => {
|
||||
const rendered = avatar(name)
|
||||
const fallbackClass = rendered.fallback ? ' tm-avatar--fallback' : ''
|
||||
return `<img class="${legacyClass ? `${legacyClass} ` : ''}tm-avatar tm-avatar--${role}${fallbackClass}" src="${escapeHtml(rendered.source)}" alt="${escapeHtml(alt)}">`
|
||||
}
|
||||
|
||||
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||
.map((name) => renderAvatar(name, 'hero', '', name))
|
||||
.join('')
|
||||
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
||||
|
||||
const topicCards = report.topics
|
||||
.map(
|
||||
(topic) => `<div class="card topic-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
(topic) => `<div class="card topic-card tm-fragment tm-topic-card">
|
||||
<div class="topic-title-row tm-topic-card__title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||
<p>${escapeHtml(topic.summary)}</p>
|
||||
${
|
||||
@@ -237,15 +342,15 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
}
|
||||
}
|
||||
if (!imageUrl) return ''
|
||||
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
return `<div class="topic-inline-image"><img src="${escapeHtml(imageUrl)}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
})()
|
||||
: ''
|
||||
}
|
||||
<div class="participants">${topic.participants
|
||||
<div class="participants tm-topic-card__participants">${topic.participants
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(name) =>
|
||||
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
||||
`<span class="person-chip tm-participant">${renderAvatar(name, 'participant')}<b class="tm-participant__name">${escapeHtml(name)}</b></span>`
|
||||
)
|
||||
.join('')}</div>
|
||||
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||
@@ -262,10 +367,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const importantMessages = report.importantMessages
|
||||
.map(
|
||||
(message) => `<div class="important-card">
|
||||
<img class="avatar" src="${avatar(message.sender)}" alt="">
|
||||
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
|
||||
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
|
||||
(message) => `<div class="important-card tm-fragment tm-message tm-message--important">
|
||||
${renderAvatar(message.sender, 'message', 'avatar')}
|
||||
<div class="important-body tm-message__body"><div class="important-meta tm-message__meta"><b class="tm-message__author">${escapeHtml(message.sender)}</b><span class="tm-message__time">${escapeHtml(message.time)}</span></div>
|
||||
<div class="important-text tm-message__text">${escapeHtml(message.content)}</div><div class="important-note tm-message__note">${escapeHtml(message.note)}</div></div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
@@ -273,12 +378,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
const quoteBlocks = report.quotes
|
||||
.map(
|
||||
(quote) =>
|
||||
`<div class="chat-block">${quote.messages
|
||||
`<div class="chat-block tm-fragment tm-quote">${quote.messages
|
||||
.map(
|
||||
(
|
||||
message
|
||||
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
|
||||
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
|
||||
) => `<div class="chat-msg tm-message tm-message--quote">${renderAvatar(message.sender, 'message', 'chat-avatar')}<div class="tm-message__body">
|
||||
<div class="chat-name tm-message__author">${escapeHtml(message.sender)}</div><div class="chat-bubble tm-message__text">${escapeHtml(message.content)}</div>
|
||||
</div></div>`
|
||||
)
|
||||
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
||||
@@ -287,7 +392,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const todoCards = (report.todos || [])
|
||||
.map(
|
||||
(item) => `<div class="action-card todo-card">
|
||||
(item) => `<div class="action-card todo-card tm-fragment tm-todo-card">
|
||||
<b>${escapeHtml(item.task)}</b>
|
||||
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||
@@ -297,7 +402,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const unresolvedCards = (report.unresolved || [])
|
||||
.map(
|
||||
(item) => `<div class="action-card unresolved-card">
|
||||
(item) => `<div class="action-card unresolved-card tm-fragment tm-unresolved-card">
|
||||
<b>${escapeHtml(item.question)}</b>
|
||||
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
<div class="action-note">${escapeHtml(item.note)}</div>
|
||||
@@ -324,7 +429,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const reversalCards = (report.reversals || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
(item) => `<div class="qa-card tm-fragment tm-reversal-card">
|
||||
<b>${escapeHtml(item.topic)}</b>
|
||||
<div>最初:${escapeHtml(item.initialView)}</div>
|
||||
<div>后来:${escapeHtml(item.finalView)}</div>
|
||||
@@ -349,7 +454,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||
.map(
|
||||
(item) => `<div class="vision-card">
|
||||
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
||||
<img class="vision-image" src="${escapeHtml(item.imageUrl)}" alt="AI 识别的图片">
|
||||
<div class="vision-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||
@@ -363,7 +468,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const voiceCards = (report.media?.voiceHighlights || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
(item) => `<div class="qa-card tm-fragment tm-voice-card">
|
||||
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
||||
<div>${escapeHtml(item.note)}</div>
|
||||
</div>`
|
||||
@@ -372,8 +477,8 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
||||
.map(
|
||||
(item, index) => `<div class="rank">
|
||||
<img src="${avatar(item.sender)}" alt="">
|
||||
(item, index) => `<div class="rank tm-fragment tm-ranking-item">
|
||||
${renderAvatar(item.sender, 'ranking')}
|
||||
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
||||
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
||||
</div>`
|
||||
@@ -394,7 +499,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(speaker, index) =>
|
||||
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||
`<div class="rank tm-fragment tm-ranking-item">${renderAvatar(speaker.name, 'ranking')}<b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -424,7 +529,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
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>`
|
||||
`<div class="qa-card tm-fragment tm-qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -441,7 +546,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
unresolvedCount: report.unresolved.length
|
||||
}
|
||||
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||
if (resolvedTemplate.source === 'installed') {
|
||||
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
TEMPLATE_LABEL: escapeHtml(template.label),
|
||||
@@ -547,14 +656,19 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||
return html
|
||||
return addReportCsp(injectReportTemplateFragmentContract(html), resolvedTemplate.source === 'builtin')
|
||||
}
|
||||
|
||||
const renderReportSnapshotHtml = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(request.templateId)
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
const template = resolvedTemplate.definition
|
||||
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||
if (resolvedTemplate.source === 'installed') {
|
||||
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||
}
|
||||
const values = {
|
||||
...request.snapshot.values,
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
@@ -565,7 +679,7 @@ const renderReportSnapshotHtml = async (
|
||||
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, '')
|
||||
return addReportCsp(html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''), resolvedTemplate.source === 'builtin')
|
||||
}
|
||||
|
||||
export const extractGroupReportRenderSnapshot = async (
|
||||
@@ -777,23 +891,68 @@ export const extractGroupReportRenderSnapshot = async (
|
||||
const captureFullPage = async (
|
||||
htmlPath: string,
|
||||
pngPath: string,
|
||||
templateId?: string
|
||||
templateId?: string,
|
||||
templateOverride?: {
|
||||
captureWidth: number
|
||||
maxCaptureWidth: number
|
||||
maxCaptureHeight?: number
|
||||
}
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(templateId)
|
||||
const template = templateOverride || getReportTemplate(templateId)
|
||||
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
|
||||
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
|
||||
const maxCaptureHeight = templateOverride?.maxCaptureHeight || 20000
|
||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||
const reportSessionPartition = `report-template-${crypto.randomUUID()}`
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
opacity: 0,
|
||||
skipTaskbar: true,
|
||||
width: captureWidth,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
partition: reportSessionPartition
|
||||
}
|
||||
})
|
||||
const allowedDocumentPath = path.resolve(htmlPath)
|
||||
const requestHandler = (
|
||||
details: Electron.OnBeforeRequestListenerDetails,
|
||||
callback: (response: { cancel: boolean }) => void
|
||||
): void => {
|
||||
if (details.url.startsWith('file:')) {
|
||||
try {
|
||||
const requestedPath = path.resolve(fileURLToPath(details.url))
|
||||
if (requestedPath === allowedDocumentPath) {
|
||||
callback({ cancel: false })
|
||||
} else {
|
||||
recordBlockedTemplateRequest(details, 'file-path-not-allowed')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
} catch {
|
||||
recordBlockedTemplateRequest(details, 'invalid-file-url')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
recordBlockedTemplateRequest(details, 'scheme-not-allowed')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
reportWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*', 'file://*/*'] },
|
||||
requestHandler
|
||||
)
|
||||
reportWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
reportWindow.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||
|
||||
try {
|
||||
const readyToShow = new Promise<void>((resolve) => reportWindow.once('ready-to-show', resolve))
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
await readyToShow
|
||||
console.log('[GroupReport] capture loaded html')
|
||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
@@ -808,17 +967,29 @@ const captureFullPage = async (
|
||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||
})`)) as { width: number; height: number }
|
||||
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)
|
||||
const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height)))
|
||||
const [currentWidth, currentHeight] = reportWindow.getContentSize()
|
||||
if (currentWidth !== width || currentHeight !== height) {
|
||||
const resized = new Promise<void>((resolve) => reportWindow.once('resize', resolve))
|
||||
reportWindow.setContentSize(width, height)
|
||||
await resized
|
||||
}
|
||||
await reportWindow.webContents.executeJavaScript(
|
||||
'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
|
||||
const image = await reportWindow.webContents.capturePage(
|
||||
{ x: 0, y: 0, width, height },
|
||||
{ stayHidden: true }
|
||||
)
|
||||
const png = image.toPNG()
|
||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||
await fs.writeFile(pngPath, png)
|
||||
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
||||
return `data:image/png;base64,${png.toString('base64')}`
|
||||
} finally {
|
||||
reportWindow.webContents.session.webRequest.onBeforeRequest(null)
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
@@ -830,10 +1001,14 @@ export const exportGroupReport = async (
|
||||
// === enrich 在 render 之前:从群成员快照反推真头像 ===
|
||||
await enrichAvatarsFromGroup(request.metadata)
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
const outputDir =
|
||||
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel =
|
||||
request.templateId === 'v1'
|
||||
const resolvedTemplate = await resolveTemplate(request)
|
||||
const templateLabel = request.templateRef
|
||||
? resolvedTemplate.definition.fileLabel
|
||||
: request.templateId === 'v1'
|
||||
? '经典版'
|
||||
: request.templateId === 'v2'
|
||||
? '丰富版'
|
||||
@@ -846,7 +1021,15 @@ export const exportGroupReport = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
@@ -877,9 +1060,12 @@ export const exportGroupReportSnapshot = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
try {
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
const outputDir =
|
||||
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel = getReportTemplate(request.templateId).fileLabel
|
||||
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
const templateLabel = resolvedTemplate.definition.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`)
|
||||
@@ -888,7 +1074,15 @@ export const exportGroupReportSnapshot = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { loadSettings } from './services/settings-store'
|
||||
import { getChatDb, getImageMessageReference, isReady } from './services/chat-service'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
|
||||
export type HttpImageResult = {
|
||||
buffer: Buffer
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export class HttpMediaError extends Error {
|
||||
constructor(
|
||||
public readonly code: 'NOT_READY' | 'NOT_FOUND' | 'NOT_IMAGE' | 'READ_FAILED',
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HttpMediaError'
|
||||
}
|
||||
}
|
||||
|
||||
let imageService: ImageDecryptService | null = null
|
||||
let imageServiceKey = ''
|
||||
let imageServiceClient: ReturnType<
|
||||
NonNullable<ReturnType<typeof getChatDb>>['getWcdb4Client']
|
||||
> | null = null
|
||||
|
||||
function getImageService(): ImageDecryptService {
|
||||
const config = new ImageKeyConfigService()
|
||||
const imageConfig = config.getConfig()
|
||||
const aesKey = imageConfig.aesKey || ''
|
||||
const xorKey = imageConfig.xorKey || '0x40'
|
||||
const key = `${xorKey}:${aesKey}`
|
||||
const client = getChatDb()?.getWcdb4Client() || null
|
||||
if (!imageService || imageServiceKey !== key || imageServiceClient !== client) {
|
||||
imageService = new ImageDecryptService(xorKey, aesKey, client, loadSettings().dbRoot)
|
||||
imageServiceKey = key
|
||||
imageServiceClient = client
|
||||
}
|
||||
return imageService
|
||||
}
|
||||
|
||||
function decodeDataUrl(value: string): HttpImageResult | null {
|
||||
const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i.exec(value)
|
||||
if (!match) return null
|
||||
return { mimeType: match[1].toLowerCase(), buffer: Buffer.from(match[2], 'base64') }
|
||||
}
|
||||
|
||||
export async function readImageMedia(messageId: string): Promise<HttpImageResult> {
|
||||
if (!isReady()) throw new HttpMediaError('NOT_READY', 'TraceMemo 数据库未初始化')
|
||||
const reference = getImageMessageReference(messageId)
|
||||
if (!reference) throw new HttpMediaError('NOT_FOUND', '未找到图片消息')
|
||||
if (!reference.imageMd5 && !reference.imageDatName) {
|
||||
throw new HttpMediaError('NOT_IMAGE', '消息不是可读取的图片消息')
|
||||
}
|
||||
|
||||
const service = getImageService()
|
||||
const filePath = await service.findImageFileAsync(reference.imageMd5, reference.imageDatName, {
|
||||
allowThumbnail: false,
|
||||
sessionId: reference.sessionId,
|
||||
createTime: reference.createTime
|
||||
})
|
||||
const fallbackPath =
|
||||
filePath ||
|
||||
(await service.findImageFileAsync(reference.imageMd5, reference.imageDatName, {
|
||||
allowThumbnail: true,
|
||||
sessionId: reference.sessionId,
|
||||
createTime: reference.createTime
|
||||
}))
|
||||
if (!fallbackPath) throw new HttpMediaError('NOT_FOUND', '图片文件不存在')
|
||||
|
||||
const decoded = await service.decryptImageToBase64WithFallbackAsync(fallbackPath, true)
|
||||
if (!decoded) throw new HttpMediaError('READ_FAILED', '图片读取或解密失败')
|
||||
const image = decodeDataUrl(decoded.data)
|
||||
if (!image || image.buffer.length === 0) {
|
||||
throw new HttpMediaError('READ_FAILED', '图片数据无效')
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
export function resetHttpMediaService(): void {
|
||||
imageService = null
|
||||
imageServiceKey = ''
|
||||
imageServiceClient = null
|
||||
}
|
||||
@@ -11,9 +11,22 @@ import {
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||
import { scheduledReportService } from './services/scheduled-report-service'
|
||||
import { personalWechatCapabilityService } from './services/personal-wechat-capability-service'
|
||||
import {
|
||||
ScheduledReportApiError,
|
||||
ScheduledReportApiService,
|
||||
type ScheduledReportApiDependencies
|
||||
} from './services/scheduled-report-api-service'
|
||||
import type {
|
||||
ScheduledReportApiCreateRequest,
|
||||
ScheduledReportApiUpdateRequest
|
||||
} from '../shared/scheduled-report-api'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
|
||||
import { LocalQueryApiService } from './services/local-query-api-service'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
@@ -33,6 +46,18 @@ interface RouteContext {
|
||||
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
||||
scheduledReportService?: ScheduledReportApiDependencies['service']
|
||||
scheduledReportCapabilityProvider?: ScheduledReportApiDependencies['getCapability']
|
||||
scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts']
|
||||
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
|
||||
scheduledReportPlatform?: NodeJS.Platform
|
||||
queryApiService?: LocalQueryApiService
|
||||
}
|
||||
|
||||
let configuredQueryApiService: LocalQueryApiService | undefined
|
||||
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
|
||||
configuredQueryApiService = service
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
@@ -65,7 +90,7 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
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-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
return true
|
||||
}
|
||||
@@ -90,6 +115,26 @@ function sendError(res: ServerResponse, status: number, message: string, extra?:
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
|
||||
function sendBinary(res: ServerResponse, status: number, result: HttpImageResult): void {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': result.mimeType,
|
||||
'Content-Length': result.buffer.length,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
})
|
||||
res.end(result.buffer)
|
||||
}
|
||||
|
||||
function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string, unknown> {
|
||||
const contentData = message.contentData
|
||||
if (!contentData || typeof contentData !== 'object' || !('aeskey' in contentData)) {
|
||||
return message
|
||||
}
|
||||
const safeContentData = { ...(contentData as Record<string, unknown>) }
|
||||
delete safeContentData.aeskey
|
||||
return { ...message, contentData: safeContentData }
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
@@ -252,7 +297,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
contact: resolved,
|
||||
query: { talker, time: timeParam, startTime, endTime },
|
||||
count: messages.length,
|
||||
messages
|
||||
messages: messages.map((message) =>
|
||||
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -294,6 +341,11 @@ const routes: Record<string, RouteHandler> = {
|
||||
if (!request?.report || !request?.metadata) {
|
||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||
}
|
||||
if (request.templateRef !== undefined) {
|
||||
return sendError(res, 400, 'HTTP API 暂不支持外部日报模板,请使用内置 templateId', {
|
||||
code: 'external_template_unsupported'
|
||||
})
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
},
|
||||
@@ -343,12 +395,257 @@ const routes: Record<string, RouteHandler> = {
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
|
||||
const WECHAT_SEND_CAPABILITY_ROUTE = '/api/v1/wechat-personal/send-capability'
|
||||
|
||||
function parseJsonBody(body: unknown): unknown {
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体不能为空')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body)
|
||||
} catch {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体 JSON 解析失败')
|
||||
}
|
||||
}
|
||||
|
||||
function sendScheduledError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof ScheduledReportApiError) {
|
||||
sendJson(res, error.status, {
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
...(error.details !== undefined ? { details: error.details } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
safeError('[HttpServer] scheduled report request failed:', error)
|
||||
sendJson(res, 500, {
|
||||
error: 'internal_error',
|
||||
message: '定时日报 API 执行失败'
|
||||
})
|
||||
}
|
||||
|
||||
function createScheduledReportApi(options: HttpServerOptions): ScheduledReportApiService {
|
||||
return new ScheduledReportApiService({
|
||||
service: options.scheduledReportService || scheduledReportService,
|
||||
getCapability:
|
||||
options.scheduledReportCapabilityProvider ||
|
||||
(() => personalWechatCapabilityService.getPersonalWechatSendCapability()),
|
||||
listContacts: options.scheduledReportContactsProvider || listContacts,
|
||||
isDatabaseReady: options.scheduledReportDatabaseReadyProvider || isReady,
|
||||
platform: options.scheduledReportPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function createScheduledReportRoute(
|
||||
pathname: string,
|
||||
api: ScheduledReportApiService
|
||||
): RouteHandler | undefined {
|
||||
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
|
||||
return async ({ req, res }) => {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
try {
|
||||
sendJson(res, 200, { capability: await api.getCapability() })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === SCHEDULED_REPORTS_ROUTE) {
|
||||
return async ({ req, res, body }) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const tasks = await api.list()
|
||||
sendJson(res, 200, { count: tasks.length, tasks })
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
sendError(res, 405, '需要 GET 或 POST 请求')
|
||||
return
|
||||
}
|
||||
const task = await api.create(parseJsonBody(body) as ScheduledReportApiCreateRequest)
|
||||
sendJson(res, 201, { created: true, task })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
|
||||
if (pathname.startsWith(retryPrefix)) {
|
||||
const segments = pathname.slice(retryPrefix.length).split('/').filter(Boolean)
|
||||
if (segments.length !== 2 || segments[1] !== 'retry-send') return undefined
|
||||
let executionId: string
|
||||
try {
|
||||
executionId = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return async ({ req, res }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
try {
|
||||
const execution = await api.retrySend(executionId)
|
||||
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
||||
if (!pathname.startsWith(prefix)) return undefined
|
||||
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
||||
if (!segments.length || segments.length > 2) return undefined
|
||||
let taskId: string
|
||||
try {
|
||||
taskId = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const action = segments[1]
|
||||
|
||||
return async ({ req, res, body }) => {
|
||||
try {
|
||||
if (!action && req.method === 'GET') {
|
||||
sendJson(res, 200, { task: await api.get(taskId) })
|
||||
return
|
||||
}
|
||||
if (!action && req.method === 'PATCH') {
|
||||
const task = await api.update(
|
||||
taskId,
|
||||
parseJsonBody(body) as ScheduledReportApiUpdateRequest
|
||||
)
|
||||
sendJson(res, 200, { updated: true, task })
|
||||
return
|
||||
}
|
||||
if (!action && req.method === 'DELETE') {
|
||||
sendJson(res, 200, { deleted: true, ...(await api.delete(taskId)) })
|
||||
return
|
||||
}
|
||||
if (action === 'enable' && req.method === 'POST') {
|
||||
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, true) })
|
||||
return
|
||||
}
|
||||
if (action === 'disable' && req.method === 'POST') {
|
||||
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, false) })
|
||||
return
|
||||
}
|
||||
if (action === 'run' && req.method === 'POST') {
|
||||
const execution = await api.run(taskId)
|
||||
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||
return
|
||||
}
|
||||
if (action === 'executions' && req.method === 'GET') {
|
||||
const executions = await api.executions(taskId)
|
||||
sendJson(res, 200, { count: executions.length, executions })
|
||||
return
|
||||
}
|
||||
sendError(res, 405, '请求方法或定时日报操作不受支持')
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
||||
const QUERY_ROUTE_PREFIX = '/api/v1/query/'
|
||||
|
||||
function queryStatusCode(status: string): number {
|
||||
if (status === 'completed') return 200
|
||||
if (status === 'contact_not_found') return 404
|
||||
if (status === 'ambiguous_contact') return 409
|
||||
if (status === 'knowledge_unavailable') return 503
|
||||
if (status === 'retrieval_incomplete') return 206
|
||||
return 400
|
||||
}
|
||||
|
||||
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
|
||||
return async ({ req, res, body }) => {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
||||
if (pathname === '/api/v1/query/capabilities') {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
return sendJson(res, 200, api.capabilities())
|
||||
}
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let payload: any
|
||||
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
|
||||
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
|
||||
try {
|
||||
const result = pathname === '/api/v1/query/messages'
|
||||
? await api.messages(payload)
|
||||
: pathname === '/api/v1/query/search'
|
||||
? await api.search(payload)
|
||||
: pathname === '/api/v1/query/message-context'
|
||||
? await api.context(payload)
|
||||
: pathname === '/api/v1/query/conversation-overview'
|
||||
? await api.overview(payload)
|
||||
: undefined
|
||||
if (!result) return sendError(res, 404, `端点不存在: ${pathname}`)
|
||||
return sendJson(res, queryStatusCode(result.status), result)
|
||||
} catch (error) {
|
||||
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMediaRoute(
|
||||
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
||||
): RouteHandler {
|
||||
return async ({ req, res, url }) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return sendError(res, 405, '需要 GET 请求')
|
||||
}
|
||||
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
|
||||
let messageId: string
|
||||
try {
|
||||
messageId = decodeURIComponent(encodedMessageId)
|
||||
} catch {
|
||||
return sendError(res, 422, 'messageId 格式无效')
|
||||
}
|
||||
if (!messageId || messageId.includes('/') || messageId.includes('\\')) {
|
||||
return sendError(res, 422, 'messageId 格式无效')
|
||||
}
|
||||
try {
|
||||
const result = await mediaProvider(messageId)
|
||||
if (req.method === 'HEAD') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': result.mimeType,
|
||||
'Content-Length': result.buffer.length,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
sendBinary(res, 200, result)
|
||||
} catch (error) {
|
||||
if (error instanceof HttpMediaError) {
|
||||
const status =
|
||||
error.code === 'NOT_READY'
|
||||
? 503
|
||||
: error.code === 'NOT_IMAGE'
|
||||
? 422
|
||||
: error.code === 'NOT_FOUND'
|
||||
? 404
|
||||
: 500
|
||||
return sendError(res, status, error.message)
|
||||
}
|
||||
safeError('[HttpServer] media request failed:', error)
|
||||
return sendError(res, 500, '图片读取失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startHttpServer(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT,
|
||||
options: HttpServerOptions = {}
|
||||
): Promise<HttpServerHandle> {
|
||||
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
||||
const mediaProvider = options.mediaProvider || readImageMedia
|
||||
const scheduledReportApi = createScheduledReportApi(options)
|
||||
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
@@ -360,7 +657,13 @@ export function startHttpServer(
|
||||
res.writeHead(204)
|
||||
return res.end()
|
||||
}
|
||||
const handler = routes[url.pathname]
|
||||
const handler =
|
||||
routes[url.pathname] ||
|
||||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
|
||||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
|
||||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
||||
? createMediaRoute(mediaProvider)
|
||||
: undefined)
|
||||
if (!handler) {
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
dialog,
|
||||
protocol
|
||||
} from 'electron'
|
||||
import { dirname, join } from 'path'
|
||||
import { dirname, extname, join } from 'path'
|
||||
import { existsSync, promises as fsPromises } from 'fs'
|
||||
import { extname } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { WechatDb } from './wechat-db'
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
type DecodedImage
|
||||
} from './image-decrypt-service'
|
||||
import {
|
||||
exportGroupReport,
|
||||
exportGroupReportSnapshot,
|
||||
extractGroupReportRenderSnapshot
|
||||
} from './group-report-service'
|
||||
@@ -44,8 +42,9 @@ import {
|
||||
saveGeneratedReport,
|
||||
updateGeneratedReportTemplate
|
||||
} from './report-history-service'
|
||||
import { reportTemplateService } from './report-template-service'
|
||||
import { registerReportTemplateIpc } from './report-template-ipc'
|
||||
import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type {
|
||||
@@ -75,7 +74,7 @@ import type {
|
||||
import { KeyServiceMac } from './key-service-mac'
|
||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||
import * as chat from './services/chat-service'
|
||||
import { apiServer } from './http-server'
|
||||
import { apiServer, setLocalQueryApiService } from './http-server'
|
||||
import { skillResourceService } from './services/skill-resource-service'
|
||||
import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWechatRunning } from './services/wechat-process-status'
|
||||
@@ -112,7 +111,39 @@ import {
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||
import { wechatActionLogService } from './services/wechat-action-log-service'
|
||||
import { wechatActionGateway } from './services/wechat-action-gateway'
|
||||
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||
import { scheduledReportService } from './services/scheduled-report-service'
|
||||
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
|
||||
import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service'
|
||||
import type {
|
||||
PersonalWechatGeneratedTtsVoiceRequest,
|
||||
PersonalWechatSendRequest,
|
||||
PersonalWechatSendResult
|
||||
} from '../shared/personal-wechat'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../shared/scheduled-report'
|
||||
import { isTruthyDebugFlag } from '../shared/debug-flags'
|
||||
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
||||
import type {
|
||||
ListTextToSpeechVoicesRequest,
|
||||
SaveTextToSpeechSettingsRequest,
|
||||
SynthesizeTextToSpeechRequest
|
||||
} from '../shared/text-to-speech'
|
||||
import { appLogger } from './app-logger'
|
||||
import { wcdbDebugLog } from './wcdb-debug'
|
||||
|
||||
let getMessagesRequestSequence = 0
|
||||
|
||||
function nextGetMessagesRequestId(): string {
|
||||
getMessagesRequestSequence += 1
|
||||
return `GETMSG-${String(getMessagesRequestSequence).padStart(3, '0')}`
|
||||
}
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { appUpdateService } from './services/app-update-service'
|
||||
import { clearCache, getCacheSummary, openKnowledgeDirectory } from './services/cache-service'
|
||||
@@ -126,13 +157,31 @@ import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-
|
||||
import { VoiceBatchService } from './voice-pipeline/voice-batch-service'
|
||||
import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition'
|
||||
import type { AiSearchPipelineRequest } from '../shared/ai-search'
|
||||
import type {
|
||||
AskWechatConfig,
|
||||
AskWechatQueryRequest,
|
||||
AskWechatQueryResult
|
||||
} from '../shared/query-agent'
|
||||
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
|
||||
// 消息身份的规范化在 main / renderer 之间必须一致,所以只从 shared 取一份实现。
|
||||
import { normalizeMessageIdentity } from '../shared/local-query-api'
|
||||
import {
|
||||
isWindowsVcRuntimeMissingError,
|
||||
WINDOWS_VC_RUNTIME_ERROR_MESSAGE
|
||||
} from '../shared/windows-runtime'
|
||||
import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
|
||||
import {
|
||||
MESSAGES_AROUND_MAX_WINDOW,
|
||||
messagesAroundRadii,
|
||||
normalizeRadiusSeconds,
|
||||
sliceMessagesAroundWindow,
|
||||
widenRadiusSeconds
|
||||
} from './services/messages-around'
|
||||
import { LocalQueryApiService } from './services/local-query-api-service'
|
||||
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
|
||||
import { QueryAgentService } from './services/query-agent-service'
|
||||
import { AskWechatService } from './services/ask-wechat-service'
|
||||
import { createLocalQueryToolExecutor } from './services/local-query-tool-executor'
|
||||
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
|
||||
import { runFirstLaunchMigration } from './app-data-migration'
|
||||
import { WechatShareConfigStore } from './wechat-share-config-store'
|
||||
@@ -151,13 +200,18 @@ let voiceService: VoiceService | null = null
|
||||
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
||||
let voiceBatchService: VoiceBatchService | null = null
|
||||
let knowledgeSearchService: KnowledgeSearchService | null = null
|
||||
let localQueryApiService: LocalQueryApiService | null = null
|
||||
let aiSearchPipelineService: AiSearchPipelineService | null = null
|
||||
let queryAgentService: QueryAgentService | null = null
|
||||
let askWechatService: AskWechatService | null = null
|
||||
let imageDecryptService: ImageDecryptService | null = null
|
||||
let stickerService: StickerService | null = null
|
||||
let videoAssetService: VideoAssetService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const aiProviderService = new AIProviderService()
|
||||
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
||||
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||
@@ -581,6 +635,23 @@ app.whenReady().then(async () => {
|
||||
knowledgeSearchService?.indexVoiceTranscript(update)
|
||||
)
|
||||
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
|
||||
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
|
||||
setLocalQueryApiService(localQueryApiService)
|
||||
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
|
||||
queryAgentService = new QueryAgentService(
|
||||
aiProviderService,
|
||||
createLocalQueryToolExecutor(localQueryApiService)
|
||||
)
|
||||
askWechatService = new AskWechatService(queryAgentService, {
|
||||
entry: 'desktop',
|
||||
// Legacy 仅在 Runtime 不可恢复错误时使用(见 AskWechatService 的 fallback 规则)。
|
||||
runLegacy: (request) => {
|
||||
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
|
||||
return aiSearchPipelineService.run(request, () => undefined)
|
||||
},
|
||||
log: (record) => appLogger.write({ level: record.level, scope: 'query-agent', message: record.message, details: record.details })
|
||||
})
|
||||
agentHubService.setQueryAgentService(queryAgentService)
|
||||
knowledgeSearchService.onStatusChange((status) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status)
|
||||
@@ -591,6 +662,11 @@ app.whenReady().then(async () => {
|
||||
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
||||
}
|
||||
})
|
||||
personalWechatRuntimeManager.setProgressListener((status) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wechat-personal:runtimeProgress', status)
|
||||
}
|
||||
})
|
||||
protocol.handle('wxe-media', async (request) => {
|
||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||
if (!filePath) return new Response('Not found', { status: 404 })
|
||||
@@ -629,6 +705,8 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
|
||||
await reportTemplateService.recover()
|
||||
registerReportTemplateIpc()
|
||||
createWindow()
|
||||
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||
console.log('[WCDB4] async bootstrap complete')
|
||||
@@ -662,6 +740,8 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('app-update:check', () => appUpdateService.check())
|
||||
ipcMain.handle('app-update:download', () => appUpdateService.download())
|
||||
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
||||
ipcMain.handle('app-update:openDownloadPage', () => appUpdateService.openDownloadPage())
|
||||
appUpdateService.scheduleStartupCheck()
|
||||
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
||||
ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory())
|
||||
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
||||
@@ -714,6 +794,7 @@ app.whenReady().then(async () => {
|
||||
chat.getCurrentAccountRoot() === selectedRoot
|
||||
) {
|
||||
console.log('[WCDB4] db:init reuse current connection')
|
||||
void groupExitMonitorService.start(true)
|
||||
return { success: true, monitoring: true }
|
||||
}
|
||||
const nextWechatDb = await WechatDb.create(key, selectedRoot)
|
||||
@@ -721,6 +802,7 @@ app.whenReady().then(async () => {
|
||||
await nextWechatDb.closeAsync()
|
||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||
}
|
||||
groupExitMonitorService.stop()
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot) {
|
||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||
@@ -736,17 +818,19 @@ app.whenReady().then(async () => {
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
|
||||
voiceRecognition?.connect(voiceService, resolvedRoot)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||
const monitoring = await wcdb4Client.startMonitor((type, json) => {
|
||||
wcdb4Client.invalidateSessionCache()
|
||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
}
|
||||
})
|
||||
void groupExitMonitorService.start(monitoring)
|
||||
const recentSession = sessions[0]
|
||||
if (recentSession?.username) {
|
||||
void wcdb4Client
|
||||
@@ -801,18 +885,22 @@ app.whenReady().then(async () => {
|
||||
)
|
||||
const self = chat.getSelfAccountInfo()
|
||||
const settings = loadSettings()
|
||||
const intelMac = process.platform === 'darwin' && process.arch === 'x64'
|
||||
const intelEnvironment = intelMac ? await keyServiceMac.getIntelEnvironmentStatus() : undefined
|
||||
const environment = {
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
osVersion: getOsVersionLabel(),
|
||||
appVersion: `v${app.getVersion()}`,
|
||||
wechatVersion: await detectWechatVersion(),
|
||||
dataStructureVersion: detectDataStructureVersion(settings.dbRoot),
|
||||
dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid,
|
||||
autoDetectSupported: process.platform === 'win32',
|
||||
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
|
||||
wechatRunning: await isWechatRunning(),
|
||||
accountIdentified: Boolean(self?.wxid),
|
||||
dbConnected: chat.isReady(),
|
||||
encryptionAvailable: storage.encryptionAvailable
|
||||
encryptionAvailable: storage.encryptionAvailable,
|
||||
...(intelEnvironment || {})
|
||||
}
|
||||
return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) }
|
||||
})
|
||||
@@ -825,6 +913,13 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('key:installIntelMacRuntime', async (event) => {
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||
}
|
||||
return keyServiceMac.installIntelKeyRuntime(onStatus)
|
||||
})
|
||||
|
||||
ipcMain.handle('key:pasteAndSaveDbKey', async (_, accountRoot: string) => {
|
||||
const clipboardKey = clipboard.readText().trim()
|
||||
return databaseKeyStore.save(String(accountRoot || ''), clipboardKey)
|
||||
@@ -847,14 +942,20 @@ app.whenReady().then(async () => {
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
|
||||
: await keyServiceMac.autoGetDbKey(onStatus)
|
||||
: await keyServiceMac.autoGetDbKey(onStatus, 60_000, accountRoot)
|
||||
if (!result.success || !result.key) return result
|
||||
|
||||
const selectedRoot = String(accountRoot || '').trim()
|
||||
if (!selectedRoot) return { success: false, error: '请先选择微信账号' }
|
||||
const validation = await chat.testConnection(result.key, selectedRoot)
|
||||
if (!validation.success) {
|
||||
return { ...result, success: false, key: undefined, error: '获取到的密钥不属于所选账号' }
|
||||
return {
|
||||
...result,
|
||||
success: false,
|
||||
key: undefined,
|
||||
error:
|
||||
'获取到的密钥不属于所选账号, 也可能是选择错误的目录了, 请打开微信设置 左下角确认目录是否和软件内填写一致'
|
||||
}
|
||||
}
|
||||
if (options?.save === false) return result
|
||||
|
||||
@@ -866,6 +967,20 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
|
||||
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
|
||||
if (request.type !== 'voice' || String(request.fromId || '').trim()) {
|
||||
return personalWechatSendService.send(request)
|
||||
}
|
||||
let fromId = ''
|
||||
try {
|
||||
const self = await chat.getSelfAccountInfoAsync()
|
||||
fromId = String(self?.wxid || '').trim()
|
||||
} catch {
|
||||
// 无法读取账号 wxid 时仍交给发送服务返回规范化错误。
|
||||
}
|
||||
return personalWechatSendService.send(fromId ? { ...request, fromId } : request)
|
||||
})
|
||||
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||
const settings = loadSettings()
|
||||
@@ -880,7 +995,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await keyServiceWin.autoGetImageKeyByMemoryScan(accountRoot, onStatus)
|
||||
? await keyServiceWin.autoGetImageKey(accountRoot, onStatus, wxid)
|
||||
: await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid)
|
||||
|
||||
if (!result.success || !result.aesKey) return result
|
||||
@@ -1035,11 +1150,14 @@ app.whenReady().then(async () => {
|
||||
return contacts
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => {
|
||||
const avatars = await chat.getContactAvatars(usernames)
|
||||
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
||||
return avatars
|
||||
})
|
||||
ipcMain.handle(
|
||||
'db:getContactAvatars',
|
||||
async (_, usernames: string[], options?: { refresh?: boolean }) => {
|
||||
const avatars = await chat.getContactAvatars(usernames, options)
|
||||
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
||||
return avatars
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getMessages',
|
||||
@@ -1050,22 +1168,143 @@ app.whenReady().then(async () => {
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
) => {
|
||||
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
||||
if (chat.isReady()) {
|
||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||
const requestId = nextGetMessagesRequestId()
|
||||
const startedAt = Date.now()
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] IPC db:getMessages start userMd5=${userMd5} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
||||
)
|
||||
try {
|
||||
const messages = await chat.listMessagesAsync(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
requestId
|
||||
)
|
||||
if (chat.isReady()) {
|
||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||
}
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] IPC db:getMessages end rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return messages
|
||||
} catch (error) {
|
||||
wcdbDebugLog(`[${requestId}] IPC db:getMessages error cost=${Date.now() - startedAt}ms`)
|
||||
throw error
|
||||
}
|
||||
return messages
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 「跳转到原聊天」的锚点读取。
|
||||
*
|
||||
* 与 `db:getMessages` 的区别:目标是**某一条消息**,不是"某个时间段"。
|
||||
* 以 `anchorSeconds` 为中心读一个**有界**窗口(默认 ±6h,找不到再放宽到 ±3d),
|
||||
* 在窗口内按规范化消息 id 精确匹配。
|
||||
*
|
||||
* 不整段加载会话历史:最大会话可达数十万条消息,整段读既慢又会挤爆 IPC;
|
||||
* 而时间窗口在真实数据上是稀疏的,±6h 的量级很小。
|
||||
*
|
||||
* `found: false` 表示"已打开会话但无法定位原消息",调用方必须走降级文案,
|
||||
* 不能假装跳转成功。
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'db:getMessagesAround',
|
||||
async (
|
||||
_,
|
||||
userMd5: string,
|
||||
messageId: string,
|
||||
anchorSeconds?: number,
|
||||
radiusSeconds?: number
|
||||
) => {
|
||||
const requestId = nextGetMessagesRequestId()
|
||||
const startedAt = Date.now()
|
||||
const target = normalizeMessageIdentity(userMd5, messageId)
|
||||
if (!target) return { messages: [], found: false, radiusSeconds: 0, truncated: false }
|
||||
const baseRadius = normalizeRadiusSeconds(radiusSeconds)
|
||||
const widenRadius = widenRadiusSeconds(baseRadius)
|
||||
const maxWindowMessages = MESSAGES_AROUND_MAX_WINDOW
|
||||
const radii = messagesAroundRadii(anchorSeconds, baseRadius, widenRadius)
|
||||
if (!radii.length) {
|
||||
// 没有时间锚点时**不能**退化成整段加载会话历史(最大会话可达数十万条,
|
||||
// 整段读既慢又会挤爆 IPC)。诚实返回"未定位到",由调用方给出降级文案。
|
||||
wcdbDebugLog(`[${requestId}] IPC db:getMessagesAround skipped reason=no-anchor`)
|
||||
return { messages: [], found: false, radiusSeconds: 0, truncated: false }
|
||||
}
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] IPC db:getMessagesAround start userMd5=${userMd5} messageId=${target.messageId} anchor=${anchorSeconds || 0}`
|
||||
)
|
||||
for (const radius of radii) {
|
||||
const start = Math.max(0, (anchorSeconds as number) - radius)
|
||||
const end = (anchorSeconds as number) + radius
|
||||
let messages: Awaited<ReturnType<typeof chat.listMessagesAsync>>
|
||||
try {
|
||||
messages = await chat.listMessagesAsync(userMd5, start, end, undefined, requestId)
|
||||
} catch (error) {
|
||||
wcdbDebugLog(`[${requestId}] IPC db:getMessagesAround error radius=${radius}`)
|
||||
throw error
|
||||
}
|
||||
const { window, index, truncated } = sliceMessagesAroundWindow(
|
||||
messages,
|
||||
userMd5,
|
||||
target.messageId,
|
||||
maxWindowMessages
|
||||
)
|
||||
if (index >= 0) {
|
||||
if (chat.isReady()) {
|
||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, start, end, window)
|
||||
}
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] IPC db:getMessagesAround found radius=${radius} rows=${window.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return { messages: window, found: true, radiusSeconds: radius, truncated }
|
||||
}
|
||||
// 窗口内没有这条消息:可能是时间戳口径漂移,也可能是它已经被删除/清理。
|
||||
// 只有还值得放宽时才继续,避免把一次点击变成全库扫描。
|
||||
if (radius === radii[radii.length - 1]) {
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] IPC db:getMessagesAround not-found rows=${window.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return { messages: window, found: false, radiusSeconds: radius, truncated }
|
||||
}
|
||||
}
|
||||
return { messages: [], found: false, radiusSeconds: 0, truncated: false }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
|
||||
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
|
||||
if (snapshot && chat.isReady()) {
|
||||
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
|
||||
return saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
|
||||
}
|
||||
return snapshot
|
||||
})
|
||||
|
||||
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
|
||||
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
||||
groupExitMonitorService.setEnabled(enabled === true)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'group-exit-monitor:setGroups',
|
||||
(_, roomIds: string[], notificationRoomIds?: string[]) =>
|
||||
groupExitMonitorService.setMonitoredRoomIds(
|
||||
Array.isArray(roomIds) ? roomIds : [],
|
||||
Array.isArray(notificationRoomIds) ? notificationRoomIds : []
|
||||
)
|
||||
)
|
||||
ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) =>
|
||||
groupExitMonitorService.setNotificationTemplate(template)
|
||||
)
|
||||
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
|
||||
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
|
||||
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
|
||||
groupExitMonitorService.resendEvent(eventId)
|
||||
)
|
||||
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
||||
groupExitMonitorService.markRead(readAt)
|
||||
)
|
||||
ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list())
|
||||
|
||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||
ipcMain.handle(
|
||||
'knowledge:search',
|
||||
@@ -1084,6 +1323,18 @@ app.whenReady().then(async () => {
|
||||
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
|
||||
return knowledgeSearchService.startCurrentAccountIndex()
|
||||
})
|
||||
/**
|
||||
* 取消正在跑的索引 pass。
|
||||
*
|
||||
* - 只中止**索引**:并发的交互查询用另一套 Abort scope,不会被连带取消;
|
||||
* - 当前 batch 安全收尾:已提交的会话保留,**不回滚**;
|
||||
* - `run_state` 落到 `cancelled`,不残留 `indexing`;
|
||||
* - 取消 ≠ 清空:下一次同步从 per-conversation checkpoint 继续,不从头全量重扫。
|
||||
*/
|
||||
ipcMain.handle('knowledge:cancelIndex', () => {
|
||||
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
|
||||
return knowledgeSearchService.cancelCurrentAccountIndex()
|
||||
})
|
||||
ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => {
|
||||
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
|
||||
return aiSearchPipelineService.run(request, (progress) => {
|
||||
@@ -1101,6 +1352,27 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
ipcMain.handle('ai-search:getProviderStatus', () => aiProviderService.getAiSearchProviderStatus())
|
||||
// 问问微信主路径:查询大脑走 Query Agent Runtime(与 Agent Hub 同一实现)。
|
||||
ipcMain.handle(
|
||||
'ask-wechat:getConfig',
|
||||
(): AskWechatConfig => ({ queryAgentEnabled: loadSettings().queryAgentEnabled !== false })
|
||||
)
|
||||
ipcMain.handle(
|
||||
'ask-wechat:query',
|
||||
(event, request: AskWechatQueryRequest): Promise<AskWechatQueryResult> => {
|
||||
if (!askWechatService) throw new Error('本地查询服务尚未初始化')
|
||||
// 进度事件只在真实 Runtime 边界产生,并且**带 requestId** 回传:
|
||||
// UI 可以据此忽略过期请求的进度(用户连问两次时不会串台)。
|
||||
return askWechatService.ask(request, 'default', (progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('ask-wechat:progress', request.requestId, progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
ipcMain.handle('ask-wechat:forgetConversation', () => {
|
||||
askWechatService?.forgetConversation()
|
||||
})
|
||||
ipcMain.handle(
|
||||
'ai-search:authorizeExternalProvider',
|
||||
(_, request: AiSearchExternalAuthorizationRequest) => {
|
||||
@@ -1135,6 +1407,28 @@ app.whenReady().then(async () => {
|
||||
aiProviderService.migrateLegacy(config)
|
||||
)
|
||||
|
||||
ipcMain.handle('tts:getSettings', () => textToSpeechSettingsService.get())
|
||||
ipcMain.handle('tts:saveSettings', (_, request: SaveTextToSpeechSettingsRequest) =>
|
||||
textToSpeechSettingsService.save(request)
|
||||
)
|
||||
ipcMain.handle('tts:listVoices', (_, request?: ListTextToSpeechVoicesRequest) =>
|
||||
textToSpeechSettingsService.listVoices(request)
|
||||
)
|
||||
ipcMain.handle('tts:synthesize', (_, request: SynthesizeTextToSpeechRequest) =>
|
||||
textToSpeechSettingsService.synthesize(request)
|
||||
)
|
||||
ipcMain.handle('tts:removeGeneratedAudio', (_, filePath: string) =>
|
||||
textToSpeechSettingsService.removeGeneratedAudio(String(filePath || ''))
|
||||
)
|
||||
ipcMain.handle('tts:openApiKeys', async () => {
|
||||
try {
|
||||
await shell.openExternal('https://fish.audio/app/api-keys/')
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法打开 Fish Audio API Key 页面' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
|
||||
try {
|
||||
if (typeof imageSource !== 'string' || !imageSource) {
|
||||
@@ -1151,9 +1445,6 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'report:exportSnapshot',
|
||||
async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request)
|
||||
@@ -1166,7 +1457,9 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
ipcMain.handle('export:selectDirectory', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, { properties: ['openDirectory', 'createDirectory'] })
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||
})
|
||||
ipcMain.handle('export:cancel', (_, jobId: string) => {
|
||||
@@ -1206,6 +1499,47 @@ app.whenReady().then(async () => {
|
||||
return deleteGeneratedReport(reportId)
|
||||
})
|
||||
|
||||
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
|
||||
ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks())
|
||||
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
|
||||
scheduledReportService.listExecutions(taskId)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:getNotificationSettings', () =>
|
||||
scheduledReportService.getNotificationSettings()
|
||||
)
|
||||
ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) =>
|
||||
scheduledReportService.setNotificationEnabled(Boolean(enabled))
|
||||
)
|
||||
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
|
||||
scheduledReportService.createTask(request)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'scheduled-report:update',
|
||||
(_, taskId: string, request: ScheduledReportUpdateInput) =>
|
||||
scheduledReportService.updateTask(taskId, request)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:delete', (_, taskId: string) =>
|
||||
scheduledReportService.deleteTask(taskId)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:setEnabled', (_, taskId: string, enabled: boolean) =>
|
||||
scheduledReportService.setTaskEnabled(taskId, Boolean(enabled))
|
||||
)
|
||||
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
|
||||
scheduledReportService.runScheduledReportNow(taskId)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:retrySend', (_, executionId: string) =>
|
||||
scheduledReportService.retryScheduledReportSend(executionId)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:testErrorNotification', (_, taskId: string) => {
|
||||
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
||||
})
|
||||
}
|
||||
return scheduledReportService.testScheduledReportErrorNotification(taskId)
|
||||
})
|
||||
|
||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||
try {
|
||||
shell.showItemInFolder(filePath)
|
||||
@@ -1233,6 +1567,51 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'voice:showSourceContextMenu',
|
||||
(
|
||||
event,
|
||||
reference: { sessionId: string; localId: number; createTime: number; svrId?: string | number }
|
||||
) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || !voiceService) return { success: false, error: '语音服务未初始化' }
|
||||
|
||||
const revealSource = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await voiceService!.resolveSource(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime,
|
||||
reference.svrId
|
||||
)
|
||||
if (!result.success) throw new Error(result.error)
|
||||
|
||||
const directory = join(app.getPath('userData'), 'voice-sources')
|
||||
await fsPromises.mkdir(directory, { recursive: true })
|
||||
const fileName = `voice_${reference.localId}_${reference.createTime}_${result.source.sourceHash.slice(0, 12)}.silk`
|
||||
const filePath = join(directory, fileName)
|
||||
await fsPromises.writeFile(filePath, result.source.data)
|
||||
shell.showItemInFolder(filePath)
|
||||
} catch (error) {
|
||||
await dialog.showMessageBox(window, {
|
||||
type: 'error',
|
||||
title: '查找语音源文件失败',
|
||||
message: '无法提取这条语音的源文件',
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: '查找语音源文件',
|
||||
click: () => void revealSource()
|
||||
}
|
||||
]).popup({ window })
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('voice:getModelStatus', async () => {
|
||||
if (!voiceRecognition) throw new Error('Voice recognition is not initialized')
|
||||
return voiceRecognition.getModelStatus()
|
||||
@@ -1585,12 +1964,21 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => {
|
||||
groupExitMonitorService.stop()
|
||||
const ok = chat.reopenWithRoot(accountRoot)
|
||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (client) {
|
||||
voiceService = new VoiceService(client)
|
||||
voiceService = new VoiceService(client, client.getAccountRoot())
|
||||
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
||||
const monitoring = await client.startMonitor((type, json) => {
|
||||
client.invalidateSessionCache()
|
||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
}
|
||||
})
|
||||
void groupExitMonitorService.start(monitoring)
|
||||
}
|
||||
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
|
||||
const settings = loadSettings()
|
||||
@@ -1624,6 +2012,7 @@ app.whenReady().then(async () => {
|
||||
voiceBatchService?.cancel()
|
||||
voiceRecognition?.disconnect()
|
||||
voiceService = null
|
||||
groupExitMonitorService.stop()
|
||||
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1702,10 +2091,113 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
|
||||
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
|
||||
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
||||
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
|
||||
ipcMain.handle('wechat-personal:getKeepProcess', () =>
|
||||
personalWechatSendService.getKeepOneBotProcess()
|
||||
)
|
||||
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
|
||||
personalWechatSendService.setKeepOneBotProcess(Boolean(keep))
|
||||
)
|
||||
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
||||
personalWechatSendService.checkWindowsStatus(port)
|
||||
)
|
||||
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
||||
personalWechatVoiceEnvironmentService.check()
|
||||
)
|
||||
ipcMain.handle('wechat-personal:installPilk', async () => {
|
||||
const result = await personalWechatVoiceEnvironmentService.installPilk()
|
||||
if (!result.success || process.platform !== 'darwin') return result
|
||||
|
||||
const senderStatus = await personalWechatSendService.getStatus()
|
||||
if (!senderStatus.oneBotPid) return result
|
||||
try {
|
||||
const restartedStatus = await personalWechatSendService.restartRuntime()
|
||||
return {
|
||||
...result,
|
||||
restarted: restartedStatus.state !== 'error',
|
||||
...(restartedStatus.state === 'error'
|
||||
? { restartError: restartedStatus.error || restartedStatus.message }
|
||||
: {})
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
...result,
|
||||
restarted: false,
|
||||
restartError: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
|
||||
try {
|
||||
await shell.openExternal('https://www.python.org/downloads/macos/')
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法打开 Python 下载页面' }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('wechat-personal:openVoiceFfmpegDownload', async () => {
|
||||
try {
|
||||
await shell.openExternal('https://brew.sh/')
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
|
||||
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
||||
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
||||
success: personalWechatRuntimeManager.cancelDownload()
|
||||
}))
|
||||
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
||||
await personalWechatSendService.terminate(true)
|
||||
return personalWechatRuntimeManager.remove()
|
||||
})
|
||||
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
|
||||
const status = await personalWechatRuntimeManager.getStatus()
|
||||
const directory = status.directory || personalWechatRuntimeManager.directory
|
||||
await fsPromises.mkdir(directory, { recursive: true })
|
||||
const error = await shell.openPath(directory)
|
||||
return error ? { success: false, error } : { success: true }
|
||||
})
|
||||
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
||||
ipcMain.handle(
|
||||
'wechat-personal:sendGeneratedTtsVoice',
|
||||
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
||||
const to = String(request?.to || '').trim()
|
||||
const filePath = String(request?.filePath || '').trim()
|
||||
let fromId = ''
|
||||
try {
|
||||
const self = await chat.getSelfAccountInfoAsync()
|
||||
fromId = String(self?.wxid || '').trim()
|
||||
} catch {
|
||||
// Windows 语音发送需要当前账号 wxid;Gateway 会继续规范化失败结果。
|
||||
}
|
||||
const action = await wechatActionGateway.execute({
|
||||
origin: 'user_tts',
|
||||
purpose: 'tts_voice',
|
||||
triggerType: 'user',
|
||||
recipient: {
|
||||
type: request?.isGroup || to.endsWith('@chatroom') ? 'group' : 'contact',
|
||||
id: to
|
||||
},
|
||||
content: { type: 'voice', path: filePath },
|
||||
metadata: fromId ? { fromId } : undefined
|
||||
})
|
||||
const sendResult =
|
||||
action.sendResult && typeof action.sendResult === 'object'
|
||||
? (action.sendResult as PersonalWechatSendResult)
|
||||
: undefined
|
||||
const status = sendResult?.status || (await personalWechatSendService.getStatus())
|
||||
return { action, status }
|
||||
}
|
||||
)
|
||||
ipcMain.handle('wechat-personal:getVoiceDiagnostic', () =>
|
||||
personalWechatSendService.getLatestVoiceDiagnostic()
|
||||
)
|
||||
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
title: '选择要测试发送的图片',
|
||||
title: '选择要发送的图片',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||
@@ -1725,6 +2217,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
await agentHubService.start(settings)
|
||||
await scheduledReportService.start()
|
||||
|
||||
setupTray()
|
||||
if (TRAY_MODE) app.dock?.hide()
|
||||
@@ -1761,12 +2254,17 @@ app.on('before-quit', (event) => {
|
||||
|
||||
void (async () => {
|
||||
agentHubService.stop()
|
||||
scheduledReportService.stop()
|
||||
groupExitMonitorService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
const [, nativeCallsDrained] = await Promise.all([
|
||||
apiServer.stop().catch(() => undefined),
|
||||
chat.closeChatDbForQuit().catch(() => false),
|
||||
voiceRecognition?.dispose().catch(() => undefined),
|
||||
knowledgeSearchService?.dispose().catch(() => undefined)
|
||||
knowledgeSearchService?.dispose().catch(() => undefined),
|
||||
personalWechatSendService.terminate().catch((error) => {
|
||||
console.warn('[Shutdown] personal WeChat sender cleanup failed:', error)
|
||||
})
|
||||
])
|
||||
if (!nativeCallsDrained) {
|
||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isValidDatabaseKey } from './database-key-store'
|
||||
import crypto from 'crypto'
|
||||
import { findResource, getResourceCandidates } from './resource-paths'
|
||||
import { detectWechatVersion } from './services/connection-diagnostics'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -24,12 +25,279 @@ export interface ImageKeyResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type XkeyHelperMode = 'legacy' | 'wechat-4.1.13'
|
||||
|
||||
export function resolveXkeyHelperMode(wechatVersion: string): XkeyHelperMode {
|
||||
return /^4\.1\.13(?:\.|$)/.test(wechatVersion.trim()) ? 'wechat-4.1.13' : 'legacy'
|
||||
}
|
||||
|
||||
export function buildXkeyHelperArguments(pid: number, timeoutMs: number): string[] {
|
||||
return [String(pid), String(timeoutMs), '--profile', 'wechat-4.1.13', '--account']
|
||||
}
|
||||
|
||||
export function buildAppleSiliconXkeyInvocation(
|
||||
wechatVersion: string,
|
||||
pid: number,
|
||||
timeoutMs: number
|
||||
): {
|
||||
mode: XkeyHelperMode
|
||||
resourceName: string
|
||||
args: string[]
|
||||
waitMs: number
|
||||
timeoutSeconds: number
|
||||
execTimeoutMs: number
|
||||
} {
|
||||
const mode = resolveXkeyHelperMode(wechatVersion)
|
||||
const isWechat413 = mode === 'wechat-4.1.13'
|
||||
const waitMs = Math.max(isWechat413 ? 120_000 : 30_000, timeoutMs)
|
||||
const timeoutSeconds = Math.ceil(waitMs / 1000) + (isWechat413 ? 10 : 30)
|
||||
return {
|
||||
mode,
|
||||
resourceName: isWechat413 ? 'xkey_helper_4_1_13' : 'xkey_helper',
|
||||
args: isWechat413 ? buildXkeyHelperArguments(pid, waitMs) : [String(pid), String(waitMs)],
|
||||
waitMs,
|
||||
timeoutSeconds,
|
||||
execTimeoutMs: isWechat413 ? timeoutSeconds * 1000 + 5_000 : waitMs + 20_000
|
||||
}
|
||||
}
|
||||
|
||||
export function mapXkeyHelperFailure(
|
||||
rawError: string,
|
||||
fallbackCode = 'HELPER_RESULT_INVALID'
|
||||
): DatabaseKeyResult {
|
||||
const normalizedError = rawError.trim().toLowerCase()
|
||||
const parsedError = rawError.match(/(?:^|[\s"])(?:\\?"?)ERROR:([^:\s"}]+):?([^"}\r\n]*)/i)
|
||||
const code = parsedError?.[1]?.toUpperCase()
|
||||
const detail = parsedError?.[2]?.trim() || ''
|
||||
|
||||
if (
|
||||
code === 'CAPTURE_TIMEOUT' ||
|
||||
normalizedError.includes('timeout waiting for breakpoint hit') ||
|
||||
normalizedError.includes('timeout waiting for sink hit') ||
|
||||
normalizedError.includes('no_breakpoint_hit')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'CAPTURE_TIMEOUT',
|
||||
error:
|
||||
'已完成管理员授权,但监听期间微信没有触发账号密钥派生。请先停留在微信登录界面,在 TraceMemo 点击“自动获取密钥”,授权后点击微信“登录”;已有登录凭据时通常不需要扫码。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error:
|
||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: '内存扫描失败:当前微信版本或运行状态暂未适配。'
|
||||
}
|
||||
}
|
||||
if (normalizedError.includes('permission denied') || code === 'PERMISSION_DENIED') {
|
||||
return {
|
||||
success: false,
|
||||
code: code || 'PERMISSION_DENIED',
|
||||
error: '管理员授权不足,无法读取微信进程内存。'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: code || fallbackCode,
|
||||
error: code
|
||||
? `密钥工具执行未完成(${code}),请确认微信仍在运行后重试。`
|
||||
: '密钥工具未返回有效密钥,请确认微信仍在运行后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
|
||||
const payloads: Record<string, unknown>[] = []
|
||||
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
||||
try {
|
||||
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
||||
} catch {
|
||||
// Ignore helper progress that is not JSON.
|
||||
}
|
||||
}
|
||||
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
||||
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
||||
if (isValidDatabaseKey(rawKey)) return { success: true, key: rawKey }
|
||||
|
||||
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
||||
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
||||
return mapXkeyHelperFailure(rawError)
|
||||
}
|
||||
|
||||
export class KeyServiceMac {
|
||||
private getHelperPath(): string {
|
||||
const helperPath = findResource('xkey_helper')
|
||||
private getMacKeyRuntimeDir(): string {
|
||||
return path.join(app.getPath('userData'), 'key-runtime')
|
||||
}
|
||||
|
||||
private async runMacKeyTool(
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string) => void
|
||||
): Promise<Record<string, unknown>> {
|
||||
const helperPath = findResource('macos-key-tool/intel_mac_key_helper')
|
||||
if (!helperPath) {
|
||||
return {
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_UNAVAILABLE',
|
||||
error: '缺少 Intel Mac 工具'
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<Record<string, unknown>>((resolve) => {
|
||||
const child = spawn(helperPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let settled = false
|
||||
let pending = ''
|
||||
let lastError = ''
|
||||
let finalPayload: Record<string, unknown> | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const finish = (payload: Record<string, unknown>): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
resolve(payload)
|
||||
}
|
||||
const consume = (chunk: Buffer): void => {
|
||||
pending += chunk.toString()
|
||||
const lines = pending.split(/\r?\n/)
|
||||
pending = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const payload = JSON.parse(line) as Record<string, unknown>
|
||||
if (payload.type === 'progress' && typeof payload.message === 'string') {
|
||||
onStatus?.(payload.message)
|
||||
}
|
||||
if (payload.type === 'result' || payload.type === 'status') finalPayload = payload
|
||||
} catch {
|
||||
// The helper contract is JSONL; ignore interpreter diagnostics.
|
||||
}
|
||||
}
|
||||
}
|
||||
child.stdout.on('data', consume)
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
lastError = `${lastError}\n${chunk.toString()}`.trim().slice(-1000)
|
||||
})
|
||||
child.on('error', (error) =>
|
||||
finish({
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_FAILED',
|
||||
error: `无法启动 Intel Mac 密钥工具:${error.message}`
|
||||
})
|
||||
)
|
||||
child.on('close', () => {
|
||||
if (pending) consume(Buffer.from('\n'))
|
||||
finish(
|
||||
finalPayload || {
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_FAILED',
|
||||
error: lastError || 'Intel Mac 密钥工具未返回结果'
|
||||
}
|
||||
)
|
||||
})
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
// The child may already have exited.
|
||||
}
|
||||
finish({
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_TIMEOUT',
|
||||
error: 'Intel Mac 密钥获取超时,请让微信回到未登录界面后重试'
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
async getIntelEnvironmentStatus(): Promise<{
|
||||
sipDisabled: boolean
|
||||
pythonAvailable: boolean
|
||||
fridaAvailable: boolean
|
||||
wechatAdhocSigned: boolean
|
||||
}> {
|
||||
const payload = await this.runMacKeyTool(
|
||||
['status', '--runtime-dir', this.getMacKeyRuntimeDir()],
|
||||
15_000
|
||||
)
|
||||
return {
|
||||
sipDisabled: payload.sipDisabled === true,
|
||||
pythonAvailable: payload.pythonAvailable === true,
|
||||
fridaAvailable: payload.fridaAvailable === true,
|
||||
wechatAdhocSigned: payload.wechatAdhocSigned === true
|
||||
}
|
||||
}
|
||||
|
||||
async installIntelKeyRuntime(
|
||||
onStatus?: (message: string) => void
|
||||
): Promise<{ success: boolean; error?: string; code?: string }> {
|
||||
if (process.platform !== 'darwin' || process.arch !== 'x64') {
|
||||
return { success: false, code: 'UNSUPPORTED_PLATFORM', error: '仅 Intel Mac 需要安装此运行环境' }
|
||||
}
|
||||
onStatus?.('正在准备连接环境,请保持网络连接…')
|
||||
const payload = await this.runMacKeyTool(
|
||||
['install', this.getMacKeyRuntimeDir()],
|
||||
5 * 60_000,
|
||||
onStatus
|
||||
)
|
||||
return {
|
||||
success: payload.success === true,
|
||||
code: typeof payload.code === 'string' ? payload.code : undefined,
|
||||
error: typeof payload.error === 'string' ? payload.error : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async captureIntelDbKey(
|
||||
accountRoot: string | undefined,
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string) => void
|
||||
): Promise<DatabaseKeyResult> {
|
||||
const selectedRoot = String(accountRoot || '').trim()
|
||||
if (!selectedRoot) return { success: false, code: 'ACCOUNT_REQUIRED', error: '请先选择微信账号' }
|
||||
const waitMs = Math.max(30_000, timeoutMs)
|
||||
const payload = await this.runMacKeyTool(
|
||||
[
|
||||
'capture',
|
||||
'--account-root',
|
||||
selectedRoot,
|
||||
'--runtime-dir',
|
||||
this.getMacKeyRuntimeDir(),
|
||||
'--diagnostic-log',
|
||||
path.join(app.getPath('logs'), 'mac-key-diagnostic.log'),
|
||||
'--timeout-ms',
|
||||
String(waitMs)
|
||||
],
|
||||
waitMs + 15_000,
|
||||
onStatus
|
||||
)
|
||||
const key = typeof payload.key === 'string' ? payload.key.trim().toLowerCase() : ''
|
||||
if (payload.success === true && isValidDatabaseKey(key)) return { success: true, key }
|
||||
return {
|
||||
success: false,
|
||||
code: typeof payload.code === 'string' ? payload.code : 'KEY_TOOL_FAILED',
|
||||
error: typeof payload.error === 'string' ? payload.error : '未捕获到微信数据库密钥'
|
||||
}
|
||||
}
|
||||
|
||||
private getHelperPath(resourceName = 'xkey_helper'): string {
|
||||
const helperPath = findResource(resourceName)
|
||||
if (!helperPath) {
|
||||
throw new Error(
|
||||
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})`
|
||||
`找不到 ${resourceName}(已检查:${getResourceCandidates(resourceName).join(';')})`
|
||||
)
|
||||
}
|
||||
return helperPath
|
||||
@@ -61,77 +329,47 @@ export class KeyServiceMac {
|
||||
// Try the next process lookup strategy.
|
||||
}
|
||||
}
|
||||
throw new Error('未找到微信主进程,请先启动并登录微信')
|
||||
}
|
||||
|
||||
private parseHelperOutput(output: string): DatabaseKeyResult {
|
||||
const payloads: Record<string, unknown>[] = []
|
||||
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
||||
try {
|
||||
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
||||
} catch {
|
||||
// Ignore helper progress that is not JSON.
|
||||
}
|
||||
}
|
||||
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
||||
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
||||
if (!isValidDatabaseKey(rawKey)) {
|
||||
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
||||
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
||||
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
|
||||
const code = parsedError?.[1]?.toUpperCase()
|
||||
const detail = parsedError?.[2]?.trim() || ''
|
||||
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error:
|
||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: rawError || '密钥工具未返回有效的 64 位密钥'
|
||||
}
|
||||
}
|
||||
return { success: true, key: rawKey }
|
||||
throw new Error('未找到微信主进程,请先启动微信并停留在登录界面')
|
||||
}
|
||||
|
||||
async autoGetDbKey(
|
||||
onStatus?: (message: string) => void,
|
||||
timeoutMs = 60_000
|
||||
timeoutMs = 60_000,
|
||||
accountRoot?: string
|
||||
): Promise<DatabaseKeyResult> {
|
||||
if (onStatus) {
|
||||
const emitStatus = onStatus
|
||||
onStatus = (message: string): void =>
|
||||
emitStatus(message.replace(/Frida/gi, '连接组件'))
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
|
||||
}
|
||||
if (await this.isSipEnabled()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
|
||||
error: '当前系统还未完成连接环境准备,请按页面提示完成设置。'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.arch === 'x64') {
|
||||
onStatus?.('Intel Mac 将通过 Frida 获取微信主密钥,请让微信停留在未登录界面')
|
||||
const result = await this.captureIntelDbKey(accountRoot, timeoutMs, onStatus)
|
||||
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||
return result
|
||||
}
|
||||
const wechatVersion = await detectWechatVersion()
|
||||
onStatus?.('正在查找微信进程...')
|
||||
const pid = await this.getWeChatPid()
|
||||
const helperPath = this.getHelperPath()
|
||||
const waitMs = Math.max(30_000, timeoutMs)
|
||||
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
|
||||
const invocation = buildAppleSiliconXkeyInvocation(wechatVersion, pid, timeoutMs)
|
||||
const isWechat413 = invocation.mode === 'wechat-4.1.13'
|
||||
const helperPath = this.getHelperPath(invocation.resourceName)
|
||||
onStatus?.('正在请求管理员授权...')
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set timeoutSec to ${timeoutSeconds}`,
|
||||
`set cmd to quoted form of helperPath & " ${invocation.args.join(' ')}"`,
|
||||
`set timeoutSec to ${invocation.timeoutSeconds}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
'set outText to do shell script cmd with administrator privileges',
|
||||
@@ -141,25 +379,57 @@ export class KeyServiceMac {
|
||||
'return "ERR::" & errNum & "::" & errMsg',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('授权后 需要在微信登录界面 点击登录微信')
|
||||
onStatus?.(
|
||||
isWechat413
|
||||
? '授权后请在微信登录界面点击“登录”,已有登录凭据时通常不需要扫码'
|
||||
: '授权后 需要在微信登录界面 点击登录微信'
|
||||
)
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/osascript',
|
||||
scriptLines.flatMap((line) => ['-e', line]),
|
||||
{ timeout: waitMs + 20_000 }
|
||||
{ timeout: invocation.execTimeoutMs }
|
||||
)
|
||||
const output = String(stdout).trim()
|
||||
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
|
||||
if (output.startsWith('ERR::')) {
|
||||
return {
|
||||
success: false,
|
||||
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
|
||||
}
|
||||
if (output.startsWith('ERR::-128')) {
|
||||
return { success: false, error: '已取消管理员授权' }
|
||||
}
|
||||
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
||||
if (output.startsWith('ERR::')) {
|
||||
const [, errorNumber = 'UNKNOWN', ...errorParts] = output.split('::')
|
||||
const result = mapXkeyHelperFailure(errorParts.join('::'), `OSASCRIPT_${errorNumber}`)
|
||||
onStatus?.('密钥获取失败')
|
||||
return result
|
||||
}
|
||||
const result = parseXkeyHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
||||
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||
return result
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
const processError = error as NodeJS.ErrnoException & {
|
||||
killed?: boolean
|
||||
signal?: NodeJS.Signals | null
|
||||
}
|
||||
if (processError.message?.includes('未找到微信主进程')) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'WECHAT_NOT_RUNNING',
|
||||
error: '未找到微信主进程,请先启动微信并停留在登录界面。'
|
||||
}
|
||||
}
|
||||
if (
|
||||
processError.killed ||
|
||||
processError.code === 'ETIMEDOUT' ||
|
||||
processError.signal === 'SIGTERM'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'AUTH_TIMEOUT',
|
||||
error: '管理员授权等待超时,请点击“自动获取密钥”后及时完成系统授权。'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: typeof processError.code === 'string' ? processError.code : 'HELPER_EXEC_FAILED',
|
||||
error: '密钥工具执行失败,请确认微信仍在运行后重试。'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { join, dirname, delimiter } from 'path'
|
||||
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
||||
import { existsSync, copyFileSync, mkdirSync, readdirSync } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import os from 'os'
|
||||
import crypto from 'crypto'
|
||||
import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
|
||||
import {
|
||||
deriveV4ImageKeys,
|
||||
extractKvcommCode,
|
||||
normalizeV4AccountId
|
||||
} from '../shared/wechat-image-key-derivation'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -105,7 +110,7 @@ export class KeyService {
|
||||
|
||||
private localizeNetworkDll(originalPath: string): string {
|
||||
try {
|
||||
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
|
||||
const tempDir = join(os.tmpdir(), 'tracememo_dll_cache')
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
@@ -361,7 +366,7 @@ export class KeyService {
|
||||
error.includes('ACCESS_DENIED') ||
|
||||
error.includes('打开目标进程失败')
|
||||
) {
|
||||
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 TraceMemo 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
}
|
||||
return error
|
||||
}
|
||||
@@ -683,7 +688,8 @@ export class KeyService {
|
||||
if (loginRequired) {
|
||||
return {
|
||||
success: false,
|
||||
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
||||
error:
|
||||
'微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
||||
logs
|
||||
}
|
||||
}
|
||||
@@ -696,8 +702,7 @@ export class KeyService {
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
void wxidParam
|
||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
|
||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress, wxidParam)
|
||||
}
|
||||
|
||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||
@@ -706,7 +711,8 @@ export class KeyService {
|
||||
|
||||
async autoGetImageKeyByMemoryScan(
|
||||
userDir: string,
|
||||
onProgress?: (message: string) => void
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||
|
||||
@@ -781,6 +787,18 @@ export class KeyService {
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
const derived = this._deriveImageKeyByLocalMetadata(
|
||||
userDir,
|
||||
wxidParam,
|
||||
ciphertext,
|
||||
xorKey,
|
||||
onProgress
|
||||
)
|
||||
if (derived) {
|
||||
onProgress?.('通过本机账号元数据推导并验证图片密钥成功')
|
||||
return { success: true, xorKey: derived.xorKey, aesKey: derived.aesKey, verified: true }
|
||||
}
|
||||
|
||||
// 2. 找微信 PID
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||
@@ -811,6 +829,73 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private _deriveImageKeyByLocalMetadata(
|
||||
userDir: string,
|
||||
wxidParam: string | undefined,
|
||||
ciphertext: Buffer,
|
||||
expectedXorKey: number,
|
||||
onProgress?: (message: string) => void
|
||||
): { xorKey: number; aesKey: string } | null {
|
||||
const codes = new Set<number>()
|
||||
for (const directory of this._getKvcommCandidates(userDir)) {
|
||||
try {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue
|
||||
const code = extractKvcommCode(entry.name)
|
||||
if (code !== null) codes.add(code)
|
||||
}
|
||||
} catch {
|
||||
// Candidate paths differ across WeChat releases and installations.
|
||||
}
|
||||
}
|
||||
|
||||
const accountIds = new Set<string>()
|
||||
for (const candidate of [wxidParam, userDir]) {
|
||||
const normalized = normalizeV4AccountId(candidate || '')
|
||||
if (normalized) accountIds.add(normalized)
|
||||
}
|
||||
onProgress?.(`正在校验本机账号元数据候选(code=${codes.size}, account=${accountIds.size})...`)
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
for (const code of codes) {
|
||||
const derived = deriveV4ImageKeys(code, accountId)
|
||||
if (!derived || derived.xorKey !== expectedXorKey) continue
|
||||
if (this._verifyAesKey(Buffer.from(derived.aesKey, 'ascii'), ciphertext)) return derived
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private _getKvcommCandidates(userDir: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (candidate: string | undefined) => {
|
||||
if (!candidate) return
|
||||
const normalized = candidate.replace(/[\\/]+$/, '')
|
||||
const identity = normalized.toLowerCase()
|
||||
if (!normalized || seen.has(identity)) return
|
||||
seen.add(identity)
|
||||
candidates.push(normalized)
|
||||
}
|
||||
|
||||
const roaming = process.env.APPDATA
|
||||
const local = process.env.LOCALAPPDATA
|
||||
add(roaming && join(roaming, 'Tencent', 'xwechat', 'net', 'kvcomm'))
|
||||
add(roaming && join(roaming, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
|
||||
add(local && join(local, 'Tencent', 'xwechat', 'net', 'kvcomm'))
|
||||
add(local && join(local, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
|
||||
add(local && join(local, 'Tencent', 'WeChat', 'xwechat', 'net', 'kvcomm'))
|
||||
|
||||
let cursor = userDir
|
||||
for (let depth = 0; cursor && depth < 6; depth++) {
|
||||
add(join(cursor, 'net', 'kvcomm'))
|
||||
const parent = dirname(cursor)
|
||||
if (parent === cursor) break
|
||||
cursor = parent
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private async _findTemplateData(
|
||||
userDir: string,
|
||||
limit: number = 32
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { monitorEventLoopDelay } from 'perf_hooks'
|
||||
import * as chat from '../services/chat-service'
|
||||
import type {
|
||||
KnowledgeAttachmentMetadata,
|
||||
KnowledgeEvidence,
|
||||
KnowledgeMessageKind,
|
||||
KnowledgePassProgress,
|
||||
KnowledgeRuntimeState,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchIpcRequest,
|
||||
@@ -32,6 +35,42 @@ const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
|
||||
const MAX_SENDER_ENRICHMENT_SESSIONS = 32
|
||||
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 增量读取时向前回看的 overlap(epoch ms)。
|
||||
*
|
||||
* delta 下界 = `checkpoint - DELTA_OVERLAP_MS`,用来吸收 timestamp 边界碰撞。
|
||||
* 它必须远大于 chunker 的 `maxGapMs`,否则跨越下界的 chunk 会缺前半段消息、重建时丢前文。
|
||||
*
|
||||
* ⚠️ 单位:这里是**毫秒**(checkpoint 本身是毫秒)。传给 WCDB 读取层之前必须换成
|
||||
* epoch **秒**(`chat.listMessagesAsync` 的 start/end 是秒)。混用会让 delta 读恒为空,
|
||||
* 且**不会报错**。
|
||||
*/
|
||||
const DELTA_OVERLAP_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* event-loop 滞后采样的分辨率(ms)。
|
||||
*
|
||||
* 用 `monitorEventLoopDelay` 的 histogram 而不是手写 setInterval:后者只能以 interval
|
||||
* 为粒度发现 stall,给不出分位数。
|
||||
*/
|
||||
const LAG_PROBE_RESOLUTION_MS = 10
|
||||
|
||||
/** histogram 的纳秒读数转毫秒。 */
|
||||
function roundMs(nanoseconds: number): number {
|
||||
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) return 0
|
||||
return Math.round(nanoseconds / 1e6)
|
||||
}
|
||||
|
||||
/** WCDB 读取通道:`interactive` = 交互查询,`background` = 后台索引 pass。 */
|
||||
type WcdbReadLane = 'interactive' | 'background'
|
||||
|
||||
type PendingWcdbRead = {
|
||||
high: boolean
|
||||
run: () => Promise<unknown>
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
type PendingVoiceTranscriptIndex = {
|
||||
update: VoiceTranscriptUpdate
|
||||
waiters: Array<{
|
||||
@@ -43,7 +82,13 @@ type PendingVoiceTranscriptIndex = {
|
||||
type SenderEnrichmentSession = {
|
||||
lastUsedAt: number
|
||||
contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>>
|
||||
groupSnapshots: Map<string, Awaited<ReturnType<typeof chat.getGroupSnapshotAsync>> | undefined>
|
||||
/**
|
||||
* conversationId → (wxid → displayName)。
|
||||
*
|
||||
* 只缓存"这个群里这些 wxid 解析出来是什么名字",不再缓存整群快照。
|
||||
* 空串表示「查过、确实没有可用名字」,用于避免同一 session 内重复查询。
|
||||
*/
|
||||
groupMemberNames: Map<string, Map<string, string>>
|
||||
}
|
||||
|
||||
function looksLikeOpaqueSenderId(value: string | undefined): boolean {
|
||||
@@ -55,6 +100,16 @@ function looksLikeOpaqueSenderId(value: string | undefined): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function conversationAliases(contact: { md5: string; m_nsUsrName: string }): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[contact.md5, contact.m_nsUsrName, `Chat_${contact.md5}`]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]): string {
|
||||
return (
|
||||
[member.groupNickname, member.wechatNickname, member.nickname, member.remark]
|
||||
@@ -71,6 +126,10 @@ function sourceMessageId(message: chat.FormattedMessage): string {
|
||||
|
||||
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
|
||||
if (message.voiceTranscript || message.type === '语音') return 'voice'
|
||||
if (message.exportMediaType === 'image' || message.exportMediaType === 'video' || message.exportMediaType === 'sticker') {
|
||||
return message.exportMediaType
|
||||
}
|
||||
if (message.exportMediaType === 'file') return 'file'
|
||||
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
|
||||
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||
? 'file'
|
||||
@@ -189,7 +248,12 @@ export class KnowledgeSearchService {
|
||||
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
|
||||
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
|
||||
private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>()
|
||||
private wcdbReadTail: Promise<void> = Promise.resolve()
|
||||
/** WCDB 读取的两条通道:交互(查询)优先于后台(索引 pass)。 */
|
||||
private readonly wcdbPending: PendingWcdbRead[] = []
|
||||
private wcdbReadBusy = false
|
||||
private interactiveQueryDepth = 0
|
||||
private interactiveIdle: Promise<void> = Promise.resolve()
|
||||
private interactiveIdleResolve: (() => void) | null = null
|
||||
private wcdbQueueMsTotal = 0
|
||||
private wcdbExecutionMsTotal = 0
|
||||
private voiceTranscriptResolver:
|
||||
@@ -198,6 +262,17 @@ export class KnowledgeSearchService {
|
||||
private voiceIndexTail: Promise<void> = Promise.resolve()
|
||||
private voiceIndexFlushScheduled = false
|
||||
private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>()
|
||||
/** 上次由查询触发的追赶同步时间,用于节流(避免每个 Query 都重跑一次索引)。 */
|
||||
private lastCatchUpRequestedAt = 0
|
||||
/** 上一遍完整索引 pass 的实际耗时;用于让"是否值得再追一遍"的门槛自我校准。 */
|
||||
private lastIndexPassMs = 0
|
||||
/** 当前/最近一次 pass 的真实进度(供 UI 区分"追新"与"补历史")。 */
|
||||
private passProgress: KnowledgePassProgress | null = null
|
||||
/** 用户是否已经请求取消当前 pass。取消后主循环在下一个安全点退出。 */
|
||||
private cancelRequested = false
|
||||
private lagHistogram: ReturnType<typeof monitorEventLoopDelay> | null = null
|
||||
private lagMaxMs = 0
|
||||
private lagStats: { p50: number; p95: number; p99: number; max: number } | null = null
|
||||
|
||||
constructor(userDataPath: string, workerPath: string) {
|
||||
this.service = new KnowledgeService(userDataPath, workerPath)
|
||||
@@ -208,13 +283,32 @@ export class KnowledgeSearchService {
|
||||
if (!accountId) return this.emptyStatus('')
|
||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||
if (this.indexing.has(accountId)) return current
|
||||
this.cancelRequested = false
|
||||
const startedAt = Date.now()
|
||||
this.startLagProbe()
|
||||
this.passProgress = {
|
||||
// 已经有分片 = 增量追新;完全没有 = 首次全量建立。
|
||||
phase: current.indexedChunkCount > 0 || current.indexedMessageCount > 0 ? 'catchup' : 'full',
|
||||
cancellable: true,
|
||||
startedAt,
|
||||
scannedMessages: 0,
|
||||
indexedMessages: 0,
|
||||
processedConversations: 0,
|
||||
totalConversations: 0,
|
||||
skippedConversations: 0,
|
||||
catchupConversations: 0,
|
||||
backfillConversations: 0,
|
||||
backfillCompletedConversations: 0,
|
||||
mainLoopLagMs: 0
|
||||
}
|
||||
const started: KnowledgeRuntimeStatus = {
|
||||
...current,
|
||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages: 0,
|
||||
totalMessages: current.sourceMessageCount,
|
||||
estimatedRemainingMs: null,
|
||||
lastError: undefined
|
||||
lastError: undefined,
|
||||
pass: { ...this.passProgress }
|
||||
}
|
||||
this.publishStatus(started)
|
||||
const task = this.indexAccount(accountId)
|
||||
@@ -229,6 +323,16 @@ export class KnowledgeSearchService {
|
||||
})
|
||||
.finally(() => {
|
||||
this.indexing.delete(accountId)
|
||||
this.stopLagProbe()
|
||||
const finishedPass = this.passProgress
|
||||
if (finishedPass) {
|
||||
// 真实结束状态:取消就是取消,绝不留一个假的 indexing。
|
||||
finishedPass.cancellable = false
|
||||
finishedPass.mainLoopLagMs = this.lagMaxMs
|
||||
if (finishedPass.phase !== 'cancelled' && finishedPass.phase !== 'error') {
|
||||
finishedPass.phase = 'idle'
|
||||
}
|
||||
}
|
||||
void this.refreshStatus(accountId).catch(() => undefined)
|
||||
})
|
||||
this.indexing.set(accountId, task)
|
||||
@@ -238,6 +342,26 @@ export class KnowledgeSearchService {
|
||||
return started
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前正在跑的索引 pass。
|
||||
*
|
||||
* 只中止**索引**(与并发查询是两套独立 Abort scope);当前 batch 安全收尾、
|
||||
* 已提交会话保留不回滚;`run_state` 落到 `cancelled`,不残留 `indexing`;
|
||||
* 下一次 catch-up / 手动同步从 per-conversation checkpoint 继续,不从头全量重扫。
|
||||
*/
|
||||
async cancelCurrentAccountIndex(): Promise<{ cancellable: boolean; cancelled: boolean }> {
|
||||
if (!this.indexing.size) return { cancellable: false, cancelled: false }
|
||||
this.cancelRequested = true
|
||||
if (this.passProgress) this.passProgress.cancellable = false
|
||||
const accountId = this.currentAccountId()
|
||||
if (accountId) {
|
||||
const current = this.statusByAccount.get(accountId)
|
||||
if (current) this.publishStatus({ ...current, pass: this.passSnapshot() })
|
||||
}
|
||||
const cancelled = await this.service.cancelIndex().catch(() => false)
|
||||
return { cancellable: true, cancelled }
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice cache remains owned by the voice pipeline. Knowledge only reads
|
||||
* a current-account snapshot while constructing a derived local index.
|
||||
@@ -306,8 +430,10 @@ export class KnowledgeSearchService {
|
||||
}
|
||||
|
||||
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
|
||||
// 源数据最新活跃时间与索引状态无关,先取一次(零额外 WCDB 调用:读的是已缓存的 Session 列表)。
|
||||
const sourceLatestAt = this.sourceLatestAt()
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return this.searchFallback(request, 'unavailable')
|
||||
if (!accountId) return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
|
||||
try {
|
||||
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
|
||||
accountId,
|
||||
@@ -319,24 +445,87 @@ export class KnowledgeSearchService {
|
||||
senderIds: request.senderIds,
|
||||
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
|
||||
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
|
||||
,conversationBoundary: request.conversationBoundary
|
||||
}
|
||||
const result = await this.searchKnowledge(searchRequest)
|
||||
// An existing derived database can answer while its next incremental pass is running.
|
||||
// Never turn an interactive global search into another full WCDB scan during that pass.
|
||||
if (result.state === 'ready' || result.evidence.length) {
|
||||
return this.toKnowledgeResult(result, request.retrievalSessionId)
|
||||
return { ...(await this.toKnowledgeResult(result, request.retrievalSessionId)), sourceLatestAt }
|
||||
}
|
||||
if (this.indexing.has(accountId)) {
|
||||
return {
|
||||
...result,
|
||||
source: 'knowledge',
|
||||
totalMessages: result.indexedMessageCount
|
||||
totalMessages: result.indexedMessageCount,
|
||||
sourceLatestAt
|
||||
}
|
||||
}
|
||||
return this.searchFallback(request, 'unavailable')
|
||||
return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
|
||||
} catch (error) {
|
||||
console.warn('[Knowledge] search failed, using legacy fallback:', error)
|
||||
return this.searchFallback(request, 'error')
|
||||
return { ...(await this.searchFallback(request, 'error')), sourceLatestAt }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
|
||||
*/
|
||||
sourceLatestAt(): number | null {
|
||||
return chat.getSourceLatestActivityMs()
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一遍完整索引 pass 的耗时(ms)。0 表示本进程还没有跑完过一遍。
|
||||
* 调用方用它作为"落后多少才值得再追一遍"的门槛下限,避免在活跃源数据上无限连续索引。
|
||||
*/
|
||||
lastPassDurationMs(): number {
|
||||
return this.lastIndexPassMs
|
||||
}
|
||||
|
||||
/** 当前是否有索引任务在跑(用于「复用当前任务」而不是再启动一个)。 */
|
||||
isIndexing(): boolean {
|
||||
// 用 Map 是否为空判断,而不是用当前 accountId 去查:
|
||||
// `currentAccountId()` 会在 `getSelfAccountInfo()` 就绪前后返回不同的值,
|
||||
// 只按单键查会漏掉"其实已经有 pass 在跑",于是又启动一个(两个 pass 抢同一个派生库)。
|
||||
return this.indexing.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动请求一次追赶同步。
|
||||
*
|
||||
* 复用现有增量通道(`startCurrentAccountIndex` 内部是增量的:未变化的会话不会重建分片);
|
||||
* 如果已经在跑就**不**再启动第二个,并把 `triggered` 标为 false,让调用方知道这是复用。
|
||||
*/
|
||||
requestCatchUp(minIntervalMs: number): { triggered: boolean; inProgress: boolean } {
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return { triggered: false, inProgress: false }
|
||||
if (this.isIndexing()) return { triggered: false, inProgress: true }
|
||||
const now = Date.now()
|
||||
if (now - this.lastCatchUpRequestedAt < minIntervalMs) {
|
||||
return { triggered: false, inProgress: false }
|
||||
}
|
||||
this.lastCatchUpRequestedAt = now
|
||||
this.startCurrentAccountIndex()
|
||||
return { triggered: true, inProgress: this.isIndexing() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 等当前索引任务结束,最多等 `budgetMs`。返回是否已经结束。
|
||||
* 全量追赶可能远超查询预算,所以这里必须是**有界**等待,不能无限阻塞交互查询。
|
||||
*/
|
||||
async waitForIndexingComplete(budgetMs: number): Promise<boolean> {
|
||||
const task = this.indexing.values().next().value as Promise<void> | undefined
|
||||
if (!task) return true
|
||||
if (budgetMs <= 0) return false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), budgetMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([task.then(() => true, () => true), timeout])
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,66 +561,244 @@ export class KnowledgeSearchService {
|
||||
}
|
||||
|
||||
private async indexAccount(accountId: string): Promise<void> {
|
||||
const contacts = await this.listContacts()
|
||||
let processedMessages = 0
|
||||
// 整遍后台索引走 background 通道:交互查询可以插到它前面,不至于被 pass 拖慢。
|
||||
const contacts = await this.listContacts('background')
|
||||
const startedAt = Date.now()
|
||||
// 源侧每会话最后活跃时间(零额外 WCDB 调用:直接来自 Session 列表)。
|
||||
const activity = chat.getConversationActivityMs()
|
||||
// 每个会话已经索引到哪(per-conversation checkpoint)。
|
||||
const marks = await this.service
|
||||
.highWaterMarks({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
|
||||
.catch(() => ({}) as Record<string, number>)
|
||||
// 上一遍记录的源侧边界:被跳过的会话已覆盖到它,不能因为"这一遍没读"而回退。
|
||||
const previouslyCoveredLatestAt =
|
||||
this.statusByAccount.get(accountId)?.indexLatestAt ?? 0
|
||||
|
||||
let scannedMessages = 0
|
||||
let indexedMessages = 0
|
||||
let sourceLatestAt = previouslyCoveredLatestAt
|
||||
let skippedConversations = 0
|
||||
let backfillCompletedConversations = 0
|
||||
|
||||
// 「追最新」与「补历史」是两个工作概念,顺序不能反:
|
||||
// catch-up:已建立 checkpoint、源侧出现新消息 → 用户最关心,排在最前;
|
||||
// backfill:从来没有 checkpoint(历史缺口)→ 必须整段读,排在后面慢慢补。
|
||||
// 这个顺序保证今天的新消息不会被历史缺口堵住。
|
||||
const catchUpContacts: typeof contacts = []
|
||||
const backfillContacts: typeof contacts = []
|
||||
for (const contact of contacts) {
|
||||
const mark = marks[contact.md5]
|
||||
if (mark !== undefined && mark > 0) {
|
||||
const previousActivity = activity.get(contact.md5)
|
||||
// 源侧最后活跃时间不晚于"已经索引到的位置"→ 这个会话没有任何新消息。
|
||||
// 直接跳过:**不读 WCDB、不传 IPC、不写索引**。
|
||||
// 这正是把「一次 pass 处理百万级消息」变成「只处理真正变过的会话」的地方。
|
||||
if (previousActivity !== undefined && previousActivity <= mark) {
|
||||
skippedConversations += 1
|
||||
continue
|
||||
}
|
||||
catchUpContacts.push(contact)
|
||||
continue
|
||||
}
|
||||
// 没有 checkpoint → 没有"已经覆盖到哪"的证据,只能整段读(历史 backfill)。
|
||||
//
|
||||
// 这里**不**用「源侧活动(Session 表)里没有它」推断「它不可能有消息」。
|
||||
// 那确实能省掉读一批空联系人的开销,但只要 Session 表在某次读取里不完整
|
||||
// (或用户删过会话),这个推断就会把一个真有消息的会话变成**永久静默不索引**。
|
||||
// 省下来的时间换不来这个风险。
|
||||
backfillContacts.push(contact)
|
||||
}
|
||||
const orderedContacts = [...catchUpContacts, ...backfillContacts]
|
||||
|
||||
if (this.passProgress) {
|
||||
this.passProgress.totalConversations = contacts.length
|
||||
this.passProgress.startedAt = startedAt
|
||||
this.passProgress.skippedConversations = skippedConversations
|
||||
this.passProgress.catchupConversations = catchUpContacts.length
|
||||
this.passProgress.backfillConversations = backfillContacts.length
|
||||
this.passProgress.backfillCompletedConversations = 0
|
||||
this.passProgress.processedConversations = skippedConversations
|
||||
}
|
||||
this.publishStatus({
|
||||
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
|
||||
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages: 0,
|
||||
totalMessages: null,
|
||||
estimatedRemainingMs: null
|
||||
estimatedRemainingMs: null,
|
||||
pass: this.passSnapshot()
|
||||
})
|
||||
for (const [index, contact] of contacts.entries()) {
|
||||
let cancelled = false
|
||||
for (const [index, contact] of orderedContacts.entries()) {
|
||||
if (this.cancelRequested) {
|
||||
cancelled = true
|
||||
break
|
||||
}
|
||||
// 交互查询进行中就让路:背景索引绝不能把用户查询拖慢(见 beginInteractiveQuery)。
|
||||
await this.interactiveIdle
|
||||
const isBackfill = index >= catchUpContacts.length
|
||||
if (this.passProgress) {
|
||||
this.passProgress.phase = isBackfill ? 'backfill' : 'catchup'
|
||||
this.passProgress.processedConversations = skippedConversations + index
|
||||
}
|
||||
const previousActivity = activity.get(contact.md5)
|
||||
const mark = marks[contact.md5]
|
||||
// WCDB rejects overlapping async pagination. Queue every archive read so
|
||||
// background indexing and an interactive fallback search can interleave safely.
|
||||
const messages = await this.listMessages(contact.md5)
|
||||
//
|
||||
// delta 读取:已建立 checkpoint 时只读 checkpoint 之后的源消息(外加有界 overlap),
|
||||
// 而不是"从历史开头全扫一遍、再判断哪些已经索引过"。
|
||||
//
|
||||
// ⚠️ 单位契约见 `DELTA_OVERLAP_MS`:checkpoint 是**毫秒**,传给 WCDB 读取层
|
||||
// 必须是**秒**,否则 delta 读会被静默过滤成空。
|
||||
const isDelta = mark !== undefined && mark > 0
|
||||
const sinceTime = isDelta
|
||||
? Math.max(0, Math.floor((mark - DELTA_OVERLAP_MS) / 1000))
|
||||
: undefined
|
||||
const messages = await this.listMessages(contact.md5, sinceTime, undefined, 'background')
|
||||
scannedMessages += messages.length
|
||||
// 这一遍扫到的源数据最新时间:用**未过滤**的原始消息计算,
|
||||
// 这样「不可建模」的消息(图片/空正文)不会让 freshness 口径偏旧。
|
||||
let conversationLatest = 0
|
||||
for (const message of messages) {
|
||||
const createTime = (message.createTime || 0) * 1000
|
||||
if (createTime > conversationLatest) conversationLatest = createTime
|
||||
}
|
||||
if (conversationLatest > sourceLatestAt) sourceLatestAt = conversationLatest
|
||||
const sourceMessages = messages
|
||||
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
|
||||
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
|
||||
await this.service.index(
|
||||
// 记录**源侧**边界(而不是索引里最后一条可建模消息的时间):
|
||||
// 否则"最后一条恰好落在图片上"的会话会永远被判成有新消息,增量永远跳不过它。
|
||||
//
|
||||
// 安全阀:delta 范围读**空**、但源侧声称有新消息 → **不推进** checkpoint。
|
||||
// 宁可下一遍重试,也不让"一次可疑的空读"升级成"谎报已覆盖"(那会静默丢消息)。
|
||||
const suspiciousEmptyDelta =
|
||||
isDelta && messages.length === 0 && (previousActivity ?? 0) > (mark ?? 0)
|
||||
const sourceHighWaterTime = suspiciousEmptyDelta
|
||||
? undefined
|
||||
: Math.max(previousActivity ?? 0, conversationLatest)
|
||||
const result = await this.service.index(
|
||||
{
|
||||
accountId,
|
||||
conversations: [
|
||||
{
|
||||
conversationId: contact.md5,
|
||||
completeSnapshot: true,
|
||||
messages: sourceMessages
|
||||
// delta 模式下绝不能声明"完整快照":否则 store 会把"不在 delta 里的历史消息"
|
||||
// 误判为被删除,从而整段重建这个会话,增量就白做了。
|
||||
completeSnapshot: !isDelta,
|
||||
messages: sourceMessages,
|
||||
...(sourceHighWaterTime && sourceHighWaterTime > 0 ? { sourceHighWaterTime } : {})
|
||||
}
|
||||
],
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||
// 只有「这一遍真的读完了全部会话」(没有任何跳过、没有取消)才写入总量口径,
|
||||
// 否则会把增量 pass 的部分计数冒充成全量。
|
||||
//
|
||||
// 这里数的是真正被建模进索引的源消息,不是"扫到的原始条数";后者(含不可建模的
|
||||
// 图片/空正文)另走 pass 进度里的 scannedMessages。两个数字回答不同问题。
|
||||
sourceMessageCount:
|
||||
index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined
|
||||
index === orderedContacts.length - 1 && skippedConversations === 0
|
||||
? indexedMessages + sourceMessages.length
|
||||
: undefined,
|
||||
sourceLatestAt:
|
||||
index === orderedContacts.length - 1 && sourceLatestAt > 0 ? sourceLatestAt : undefined
|
||||
},
|
||||
(progress) => {
|
||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||
this.publishStatus({
|
||||
...current,
|
||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages: processedMessages + progress.processedMessages,
|
||||
processedMessages: indexedMessages + progress.processedMessages,
|
||||
totalMessages: null,
|
||||
currentConversationId: progress.conversationId,
|
||||
estimatedRemainingMs: null
|
||||
estimatedRemainingMs: null,
|
||||
pass: this.passSnapshot()
|
||||
})
|
||||
}
|
||||
)
|
||||
processedMessages += sourceMessages.length
|
||||
if (result.cancelled) {
|
||||
cancelled = true
|
||||
indexedMessages += result.processedMessages
|
||||
break
|
||||
}
|
||||
indexedMessages += sourceMessages.length
|
||||
if (isBackfill) backfillCompletedConversations += 1
|
||||
if (this.passProgress) {
|
||||
this.passProgress.scannedMessages = scannedMessages
|
||||
this.passProgress.indexedMessages = indexedMessages
|
||||
this.passProgress.processedConversations = skippedConversations + index + 1
|
||||
this.passProgress.backfillCompletedConversations = backfillCompletedConversations
|
||||
}
|
||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||
this.publishStatus({
|
||||
...current,
|
||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages,
|
||||
processedMessages: indexedMessages,
|
||||
totalMessages: null,
|
||||
currentConversationId: contact.md5,
|
||||
estimatedRemainingMs: null
|
||||
estimatedRemainingMs: null,
|
||||
pass: this.passSnapshot()
|
||||
})
|
||||
}
|
||||
if (cancelled && this.passProgress) this.passProgress.phase = 'cancelled'
|
||||
// 一遍 pass 一行汇总(低频、只在结束时输出一次)。这些数字同时喂给 UI 的 pass 进度;
|
||||
// lag 输出分位数而不是单点,因为"最大值看着还行"不能说明没有 stall。
|
||||
console.info(
|
||||
`[Knowledge] pass ${cancelled ? 'cancelled' : 'done'} conversations=${contacts.length} ` +
|
||||
`skipped=${skippedConversations} scanned=${scannedMessages} indexed=${indexedMessages} ` +
|
||||
`catchup=${catchUpContacts.length} backfill=${backfillContacts.length}/${backfillCompletedConversations} ` +
|
||||
`elapsedMs=${Date.now() - startedAt} ` +
|
||||
`lagP50=${this.lagStats?.p50 ?? 0} lagP95=${this.lagStats?.p95 ?? 0} ` +
|
||||
`lagP99=${this.lagStats?.p99 ?? 0} lagMax=${this.lagStats?.max ?? this.lagMaxMs} ` +
|
||||
`activityEntries=${activity.size} checkpointMarks=${Object.keys(marks).length}`
|
||||
)
|
||||
await this.refreshStatus(accountId, {
|
||||
processedMessages,
|
||||
totalMessages: processedMessages,
|
||||
processedMessages: indexedMessages,
|
||||
totalMessages: null,
|
||||
startedAt
|
||||
})
|
||||
// 取消的一遍不计入"上一遍耗时",否则查询侧的门槛会被一次提前结束的 pass 带偏。
|
||||
if (!cancelled) this.lastIndexPassMs = Date.now() - startedAt
|
||||
}
|
||||
|
||||
/** 当前 pass 进度的不可变快照(含实时采样到的主线程滞后)。 */
|
||||
private passSnapshot(): KnowledgePassProgress | undefined {
|
||||
if (!this.passProgress) return undefined
|
||||
// 运行期间也要能读到"此刻为止"的最大滞后,而不是等 pass 结束才有数字。
|
||||
const liveMax = this.lagHistogram ? roundMs(this.lagHistogram.max) : this.lagMaxMs
|
||||
return { ...this.passProgress, mainLoopLagMs: Math.max(liveMax, 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 pass 开始时启动主线程滞后采样。这是"重活没有压在主线程上"的直接证据。
|
||||
*/
|
||||
private startLagProbe(): void {
|
||||
if (this.lagHistogram) return
|
||||
this.lagMaxMs = 0
|
||||
this.lagStats = null
|
||||
try {
|
||||
const histogram = monitorEventLoopDelay({ resolution: LAG_PROBE_RESOLUTION_MS })
|
||||
histogram.enable()
|
||||
this.lagHistogram = histogram
|
||||
} catch {
|
||||
// 采样失败不应该影响索引本身;退化为"没有 lag 数据"。
|
||||
this.lagHistogram = null
|
||||
}
|
||||
}
|
||||
|
||||
private stopLagProbe(): void {
|
||||
const histogram = this.lagHistogram
|
||||
if (!histogram) return
|
||||
histogram.disable()
|
||||
this.lagStats = {
|
||||
p50: roundMs(histogram.percentile(50)),
|
||||
p95: roundMs(histogram.percentile(95)),
|
||||
p99: roundMs(histogram.percentile(99)),
|
||||
max: roundMs(histogram.max)
|
||||
}
|
||||
this.lagMaxMs = Math.max(this.lagMaxMs, this.lagStats.max)
|
||||
this.lagHistogram = null
|
||||
}
|
||||
|
||||
private async searchFallback(
|
||||
@@ -442,7 +809,9 @@ export class KnowledgeSearchService {
|
||||
const contacts = await this.listContacts()
|
||||
const allowedConversations = new Set(request.conversationIds || [])
|
||||
const sourceContacts = allowedConversations.size
|
||||
? contacts.filter((contact) => allowedConversations.has(contact.md5))
|
||||
? contacts.filter((contact) =>
|
||||
conversationAliases(contact).some((alias) => allowedConversations.has(alias))
|
||||
)
|
||||
: contacts
|
||||
const senderIds = new Set(request.senderIds || [])
|
||||
const terms = request.terms.filter((term) => term.trim().length >= 2)
|
||||
@@ -469,12 +838,14 @@ export class KnowledgeSearchService {
|
||||
.filter(({ message, score }) => {
|
||||
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
|
||||
const termMatches = !terms.length || score > 0
|
||||
return senderMatches && termMatches
|
||||
const boundaryMatches = !request.conversationBoundary || message.contentData?.type !== 'system'
|
||||
return senderMatches && termMatches && boundaryMatches
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
(right.message.createTime || 0) - (left.message.createTime || 0)
|
||||
(request.conversationBoundary === 'first' ? -1 : 1) *
|
||||
((right.message.createTime || 0) - (left.message.createTime || 0))
|
||||
)
|
||||
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
|
||||
const result: KnowledgeSearchIpcResult = {
|
||||
@@ -483,6 +854,9 @@ export class KnowledgeSearchService {
|
||||
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
|
||||
indexedMessageCount: 0,
|
||||
indexedChunkCount: 0,
|
||||
// fallback 是直接扫源数据,不走派生索引,因此没有索引覆盖口径可言。
|
||||
indexLatestAt: null,
|
||||
sourceLatestAt: this.sourceLatestAt(),
|
||||
totalMessages,
|
||||
timings: {
|
||||
...emptyKnowledgeSearchTimings(),
|
||||
@@ -620,6 +994,9 @@ export class KnowledgeSearchService {
|
||||
voiceCoverage.voiceCoverageComplete =
|
||||
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
|
||||
}
|
||||
const indexLatestParts = partialResults
|
||||
.map((result) => result.indexLatestAt)
|
||||
.filter((value): value is number => typeof value === 'number' && value > 0)
|
||||
return {
|
||||
state: partialResults.some((result) => result.state === 'ready')
|
||||
? 'ready'
|
||||
@@ -628,6 +1005,8 @@ export class KnowledgeSearchService {
|
||||
: 'unavailable',
|
||||
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
|
||||
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
|
||||
// 多个分片取最新的那个:只要有一部分索引更新,整体覆盖口径就按它算。
|
||||
indexLatestAt: indexLatestParts.length ? Math.max(...indexLatestParts) : null,
|
||||
evidence: mergedEvidence,
|
||||
timings,
|
||||
voiceCoverage
|
||||
@@ -661,16 +1040,20 @@ export class KnowledgeSearchService {
|
||||
}
|
||||
}
|
||||
|
||||
private listContacts(): ReturnType<typeof chat.listContactsAsync> {
|
||||
return this.enqueueWcdbRead(() => chat.listContactsAsync())
|
||||
private listContacts(lane: WcdbReadLane = 'interactive'): ReturnType<typeof chat.listContactsAsync> {
|
||||
return this.enqueueWcdbRead(() => chat.listContactsAsync(), lane)
|
||||
}
|
||||
|
||||
private listMessages(
|
||||
conversationId: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
endTime?: number,
|
||||
lane: WcdbReadLane = 'interactive'
|
||||
): ReturnType<typeof chat.listMessagesAsync> {
|
||||
return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime))
|
||||
return this.enqueueWcdbRead(
|
||||
() => chat.listMessagesAsync(conversationId, startTime, endTime),
|
||||
lane
|
||||
)
|
||||
}
|
||||
|
||||
private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage {
|
||||
@@ -792,22 +1175,36 @@ export class KnowledgeSearchService {
|
||||
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
|
||||
},
|
||||
source: 'knowledge',
|
||||
totalMessages: result.indexedMessageCount
|
||||
totalMessages: result.indexedMessageCount,
|
||||
sourceLatestAt: this.sourceLatestAt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence 的 sender 显示名 enrichment。
|
||||
*
|
||||
* 只做「取名字」这一件事:按 conversation 聚合 evidence 真正需要的 wxid(不是整群成员),
|
||||
* 每群一次批量 name lookup(`getGroupMemberNamesAsync`),**不**构造完整 GroupSnapshot、
|
||||
* **不** hydrate 头像 —— 后者会把整群成员的头像一起读出来,为拿几个名字付整群成本。
|
||||
*
|
||||
* 头像不属于 Query Tool 的成本;若 Evidence UI 将来要头像,走 lazy 路径。
|
||||
*/
|
||||
private async enrichEvidenceSenders(
|
||||
evidence: KnowledgeEvidence[],
|
||||
retrievalSessionId?: string
|
||||
): Promise<KnowledgeEvidence[]> {
|
||||
const candidateConversationIds = Array.from(
|
||||
new Set(
|
||||
evidence
|
||||
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender))
|
||||
.map((item) => item.conversationId)
|
||||
)
|
||||
).slice(0, MAX_SENDER_NAME_CONVERSATIONS)
|
||||
if (!candidateConversationIds.length) return evidence
|
||||
// 先按会话聚合需要的 sender,避免"每条 evidence 一次调用"。
|
||||
const wxidsByConversation = new Map<string, Set<string>>()
|
||||
for (const item of evidence) {
|
||||
if (!item.senderId || !looksLikeOpaqueSenderId(item.sender)) continue
|
||||
let bucket = wxidsByConversation.get(item.conversationId)
|
||||
if (!bucket) {
|
||||
bucket = new Set<string>()
|
||||
wxidsByConversation.set(item.conversationId, bucket)
|
||||
}
|
||||
bucket.add(item.senderId)
|
||||
}
|
||||
if (!wxidsByConversation.size) return evidence
|
||||
|
||||
const session = retrievalSessionId
|
||||
? this.senderEnrichmentSession(retrievalSessionId)
|
||||
@@ -817,20 +1214,35 @@ export class KnowledgeSearchService {
|
||||
const groupConversationIds = new Set(
|
||||
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
|
||||
)
|
||||
|
||||
const candidateConversationIds = Array.from(wxidsByConversation.keys())
|
||||
.filter((conversationId) => groupConversationIds.has(conversationId))
|
||||
.slice(0, MAX_SENDER_NAME_CONVERSATIONS)
|
||||
|
||||
const memberNamesByConversation = new Map<string, Map<string, string>>()
|
||||
for (const conversationId of candidateConversationIds) {
|
||||
if (!groupConversationIds.has(conversationId)) continue
|
||||
let snapshot = session?.groupSnapshots.get(conversationId)
|
||||
if (!snapshot) {
|
||||
snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId))
|
||||
session?.groupSnapshots.set(conversationId, snapshot)
|
||||
const requested = Array.from(wxidsByConversation.get(conversationId) || [])
|
||||
if (!requested.length) continue
|
||||
let memberNames = session?.groupMemberNames.get(conversationId)
|
||||
// 只查缓存里还没有的 wxid —— 同一 session 的后续 probe 因此不会重复读 WCDB。
|
||||
const missing = requested.filter((wxid) => !memberNames?.has(wxid))
|
||||
if (missing.length) {
|
||||
const members = await this.enqueueWcdbRead(() =>
|
||||
chat.getGroupMemberNamesAsync(conversationId, missing)
|
||||
)
|
||||
if (!memberNames) {
|
||||
memberNames = new Map<string, string>()
|
||||
session?.groupMemberNames.set(conversationId, memberNames)
|
||||
}
|
||||
for (const member of members) {
|
||||
memberNames.set(member.wxid, groupMemberDisplayName(member))
|
||||
}
|
||||
// 请求了但没有返回名字的 wxid 也标记为"查过",避免后续 probe 反复重查。
|
||||
for (const wxid of missing) {
|
||||
if (!memberNames.has(wxid)) memberNames.set(wxid, '')
|
||||
}
|
||||
}
|
||||
const memberNames = new Map(
|
||||
(snapshot?.members || [])
|
||||
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
|
||||
.filter(([, name]) => Boolean(name))
|
||||
)
|
||||
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
|
||||
if (memberNames?.size) memberNamesByConversation.set(conversationId, memberNames)
|
||||
}
|
||||
|
||||
return evidence.map((item) => {
|
||||
@@ -848,7 +1260,7 @@ export class KnowledgeSearchService {
|
||||
}
|
||||
let session = this.senderEnrichmentSessions.get(retrievalSessionId)
|
||||
if (!session) {
|
||||
session = { lastUsedAt: now, groupSnapshots: new Map() }
|
||||
session = { lastUsedAt: now, groupMemberNames: new Map() }
|
||||
this.senderEnrichmentSessions.set(retrievalSessionId, session)
|
||||
}
|
||||
session.lastUsedAt = now
|
||||
@@ -860,24 +1272,76 @@ export class KnowledgeSearchService {
|
||||
return session
|
||||
}
|
||||
|
||||
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const enqueuedAt = Date.now()
|
||||
const run = async (): Promise<T> => {
|
||||
const startedAt = Date.now()
|
||||
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
this.wcdbExecutionMsTotal += Date.now() - startedAt
|
||||
}
|
||||
/**
|
||||
* 交互查询进行中:后台索引会让路。
|
||||
*
|
||||
* 追赶同步会自动遍历上千个会话;如果不让路,一次用户查询会和后台 pass 抢同一个
|
||||
* Worker 与 WCDB 读取通道,被拖到几十秒 —— 查询不能因为索引 backlog 卡住。
|
||||
*/
|
||||
beginInteractiveQuery(): void {
|
||||
if (this.interactiveQueryDepth === 0) {
|
||||
this.interactiveIdle = new Promise<void>((resolve) => {
|
||||
this.interactiveIdleResolve = resolve
|
||||
})
|
||||
}
|
||||
const result = this.wcdbReadTail.then(run, run)
|
||||
// Keep the queue usable after a read failure while returning that failure to its caller.
|
||||
this.wcdbReadTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
this.interactiveQueryDepth += 1
|
||||
}
|
||||
|
||||
endInteractiveQuery(): void {
|
||||
this.interactiveQueryDepth = Math.max(0, this.interactiveQueryDepth - 1)
|
||||
if (this.interactiveQueryDepth === 0) {
|
||||
this.interactiveIdleResolve?.()
|
||||
this.interactiveIdleResolve = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WCDB 异步分页不允许重叠,所有会话读取都必须串行。
|
||||
*
|
||||
* 但**后台索引**与**交互查询**不能同权排队:追赶同步会在后台遍历上千个会话,
|
||||
* 交互读取排在它后面就会被拖成几十秒。因此分两条通道,交互读取优先于尚未开始的后台读取;
|
||||
* 交互查询最多只等"一个正在执行的读"(WCDB 不允许重叠,这点无法避免)。
|
||||
*/
|
||||
private enqueueWcdbRead<T>(operation: () => Promise<T>, lane: WcdbReadLane = 'interactive'): Promise<T> {
|
||||
const enqueuedAt = Date.now()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const pending: PendingWcdbRead = {
|
||||
high: lane === 'interactive',
|
||||
run: async () => {
|
||||
const startedAt = Date.now()
|
||||
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
this.wcdbExecutionMsTotal += Date.now() - startedAt
|
||||
}
|
||||
},
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject
|
||||
}
|
||||
if (pending.high) {
|
||||
const firstBackground = this.wcdbPending.findIndex((item) => !item.high)
|
||||
if (firstBackground < 0) this.wcdbPending.push(pending)
|
||||
else this.wcdbPending.splice(firstBackground, 0, pending)
|
||||
} else {
|
||||
this.wcdbPending.push(pending)
|
||||
}
|
||||
this.drainWcdbReads()
|
||||
})
|
||||
}
|
||||
|
||||
private drainWcdbReads(): void {
|
||||
if (this.wcdbReadBusy) return
|
||||
const next = this.wcdbPending.shift()
|
||||
if (!next) return
|
||||
this.wcdbReadBusy = true
|
||||
void next
|
||||
.run()
|
||||
.then(next.resolve, next.reject)
|
||||
.finally(() => {
|
||||
this.wcdbReadBusy = false
|
||||
this.drainWcdbReads()
|
||||
})
|
||||
}
|
||||
|
||||
private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
|
||||
@@ -892,7 +1356,10 @@ export class KnowledgeSearchService {
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 0,
|
||||
walBytes: 0,
|
||||
shmBytes: 0
|
||||
shmBytes: 0,
|
||||
indexLatestAt: null,
|
||||
sourceLatestAt: null,
|
||||
pass: this.passSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,20 +1372,29 @@ export class KnowledgeSearchService {
|
||||
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
|
||||
const current = this.statusByAccount.get(accountId)
|
||||
const indexing = this.indexing.has(accountId)
|
||||
const pass = this.passProgress
|
||||
const processedMessages =
|
||||
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
|
||||
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
|
||||
const state = indexing
|
||||
// 一遍 pass 结束后,worker 只知道「派生库能不能查」,不知道这一遍是**被取消**还是**出错**。
|
||||
// 这两个语义只在这里有(worker 侧的 run_state 已经落库),所以由本地 pass 覆盖,
|
||||
// 避免取消之后又冒充成一个干净的 ready。
|
||||
const state: KnowledgeRuntimeState = indexing
|
||||
? remote.indexedMessageCount > 0
|
||||
? 'syncing'
|
||||
: 'building'
|
||||
: remote.state
|
||||
: pass && (pass.phase === 'cancelled' || pass.phase === 'error')
|
||||
? pass.phase
|
||||
: remote.state
|
||||
const status: KnowledgeRuntimeStatus = {
|
||||
...remote,
|
||||
state,
|
||||
processedMessages,
|
||||
totalMessages,
|
||||
estimatedRemainingMs: null
|
||||
estimatedRemainingMs: null,
|
||||
// 派生库自己看不到源数据;这里补上源侧最新活跃时间,UI 才能区分 READY 与 FRESH。
|
||||
sourceLatestAt: this.sourceLatestAt(),
|
||||
pass: this.passSnapshot()
|
||||
}
|
||||
this.publishStatus(status)
|
||||
return status
|
||||
|
||||
@@ -48,6 +48,16 @@ export class KnowledgeService {
|
||||
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 每个会话已经索引到的源侧时刻(epoch ms);增量 pass 用它跳过没有变化的会话。 */
|
||||
highWaterMarks(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<Record<string, number>> {
|
||||
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务;查询请求不受影响。 */
|
||||
cancelIndex(): Promise<boolean> {
|
||||
return this.worker.cancelActiveIndex()
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
return this.worker.dispose()
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ export class KnowledgeStore {
|
||||
async index(
|
||||
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
|
||||
sourceMessageCount?: number
|
||||
sourceLatestAt?: number
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
@@ -242,6 +243,9 @@ export class KnowledgeStore {
|
||||
this.writeMeta('source_message_count', String(request.sourceMessageCount))
|
||||
this.refreshStatsSnapshot()
|
||||
}
|
||||
if (request.sourceLatestAt !== undefined) {
|
||||
this.writeMeta('source_latest_at', String(request.sourceLatestAt))
|
||||
}
|
||||
this.setRunState('ready')
|
||||
return {
|
||||
accountId: this.accountId,
|
||||
@@ -293,23 +297,64 @@ export class KnowledgeStore {
|
||||
}
|
||||
|
||||
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
|
||||
this.ensureStatsSnapshot()
|
||||
this.ensureStatsSnapshotForQuery()
|
||||
const indexedMessageCount = this.readStatNumber('stats_message_count')
|
||||
const indexedChunkCount = this.readStatNumber('stats_chunk_count')
|
||||
const runState = this.readMeta('run_state')
|
||||
return {
|
||||
state:
|
||||
runState === 'indexing'
|
||||
? 'indexing'
|
||||
: runState === 'ready' && indexedChunkCount > 0
|
||||
? 'ready'
|
||||
: 'unavailable',
|
||||
// READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在
|
||||
// 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。
|
||||
// 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。
|
||||
state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable',
|
||||
indexedMessageCount,
|
||||
indexedChunkCount,
|
||||
indexLatestAt: this.readIndexLatestAt(),
|
||||
timings: emptyKnowledgeSearchTimings()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
|
||||
*
|
||||
* 优先用 per-conversation 的 `source_high_water_time` 聚合(`MAX(...)`):每个**成功处理**
|
||||
* 的会话都会立刻推进并持久化它(不是等整遍 pass 结束才写),所以增量 pass 同样能让
|
||||
* freshness 前进。
|
||||
*
|
||||
* 为什么这是源侧口径:它是会话最后活跃时间与原始消息 create_time 的最大值,对不可建模的
|
||||
* 图片/空正文也照算,不是"索引里最新一条可建模消息的时间",所以不会被不可建模消息带偏;
|
||||
* 并且它在 delta 空读可疑时**不推进**(安全阀在 KnowledgeSearchService 侧),
|
||||
* 不会把"没读到"谎报成"已覆盖"。
|
||||
*
|
||||
* 不用 `source_latest_at` meta 优先:它的写入条件要求「这一遍没有任何跳过」,
|
||||
* 而增量世界里"有跳过"是常态,于是它会冻结在最后一次全量 pass 的值上,
|
||||
* 导致 `isKnowledgeFresh()` 恒为 false。
|
||||
*
|
||||
* 回退顺序:老库没有该列(或整列为 NULL)→ `source_latest_at` meta →
|
||||
* `MAX(high_water_time)`(被建模消息的最新时间,只会偏旧,作下限是安全的)。
|
||||
*/
|
||||
private readIndexLatestAt(): number | null {
|
||||
try {
|
||||
const covered = this.database
|
||||
.prepare('SELECT MAX(source_high_water_time) AS latest FROM knowledge_index_state')
|
||||
.get() as DbRow | undefined
|
||||
const value = Number(covered?.latest)
|
||||
if (Number.isFinite(value) && value > 0) return value
|
||||
} catch {
|
||||
// 老库可能还没有 source_high_water_time 这一列(migration 之前)→ 走回退。
|
||||
}
|
||||
const recorded = Number(this.readMeta('source_latest_at'))
|
||||
if (Number.isFinite(recorded) && recorded > 0) return recorded
|
||||
try {
|
||||
const row = this.database
|
||||
.prepare('SELECT MAX(high_water_time) AS latest FROM knowledge_index_state')
|
||||
.get() as DbRow | undefined
|
||||
const value = Number(row?.latest)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getRuntimeStatus(): KnowledgeRuntimeStatus {
|
||||
const search = this.getSearchStatus()
|
||||
const storage = this.getStorageStats()
|
||||
@@ -320,7 +365,20 @@ export class KnowledgeStore {
|
||||
const error = this.readMeta('run_error') || undefined
|
||||
return {
|
||||
accountId: this.accountId,
|
||||
state: runState === 'error' ? 'error' : search.state === 'ready' ? 'ready' : 'unavailable',
|
||||
// 注意区分三种「不是 ready」:
|
||||
// - 'error' :这一遍真的失败了;
|
||||
// - 'cancelled' :用户主动取消(已提交的会话保留、可继续,绝不留一个假的 indexing);
|
||||
// - 'unavailable':还没有任何可用分片。
|
||||
// 这里**不**用 `search.state`(那是「能不能查」):取消后派生库仍然可查,
|
||||
// 但 UI 需要区分「可用 · 已追至最新」与「可用 · 同步已取消」。
|
||||
state:
|
||||
runState === 'error'
|
||||
? 'error'
|
||||
: runState === 'cancelled'
|
||||
? 'cancelled'
|
||||
: search.state === 'ready'
|
||||
? 'ready'
|
||||
: 'unavailable',
|
||||
indexedMessageCount: search.indexedMessageCount,
|
||||
indexedChunkCount: search.indexedChunkCount,
|
||||
sourceMessageCount,
|
||||
@@ -330,7 +388,10 @@ export class KnowledgeStore {
|
||||
databaseBytes: storage.databaseBytes,
|
||||
walBytes: storage.walBytes,
|
||||
shmBytes: storage.shmBytes,
|
||||
lastError: error
|
||||
lastError: error,
|
||||
// sourceLatestAt 属于源数据(WCDB),派生库本身看不到,由 KnowledgeSearchService 补齐。
|
||||
indexLatestAt: search.indexLatestAt,
|
||||
sourceLatestAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +406,7 @@ export class KnowledgeStore {
|
||||
} {
|
||||
const startedAt = Date.now()
|
||||
let ftsMs = 0
|
||||
let shortTermSearchMs = 0
|
||||
let messageLoadMs = 0
|
||||
let chunkExpandMs = 0
|
||||
let rankingMs = 0
|
||||
@@ -358,6 +420,36 @@ export class KnowledgeStore {
|
||||
])
|
||||
).filter(Boolean)
|
||||
const senderIds = new Set(query.senderIds || [])
|
||||
if (query.conversationBoundary && conversationIds.length === 1) {
|
||||
const direction = query.conversationBoundary === 'first' ? 'ASC' : 'DESC'
|
||||
const row = this.database
|
||||
.prepare(
|
||||
`SELECT conversation_id, message_id, create_time, searchable_text, kind, sender_id, sender_name
|
||||
FROM knowledge_messages
|
||||
WHERE conversation_id = ? AND kind <> 'system'
|
||||
ORDER BY create_time ${direction}, message_id ${direction}
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(conversationIds[0]) as DbRow | undefined
|
||||
const count = this.database
|
||||
.prepare('SELECT COUNT(*) AS total FROM knowledge_messages WHERE conversation_id = ?')
|
||||
.get(conversationIds[0]) as DbRow | undefined
|
||||
const indexState = this.database
|
||||
.prepare('SELECT state, complete_snapshot FROM knowledge_index_state WHERE conversation_id = ?')
|
||||
.get(conversationIds[0]) as DbRow | undefined
|
||||
return {
|
||||
evidence: row ? [this.metadataEvidence(row)] : [],
|
||||
conversationRetrieval: {
|
||||
conversationId: conversationIds[0],
|
||||
totalMessages: Number(count?.total || 0),
|
||||
chunkCount: 0,
|
||||
candidateMessages: row ? 1 : 0,
|
||||
systemMessagesDeprioritized: 0,
|
||||
complete: String(indexState?.state || '') === 'ready' && Number(indexState?.complete_snapshot || 0) === 1
|
||||
},
|
||||
timings: { ...emptyKnowledgeSearchTimings(), messageLoadMs: Date.now() - startedAt, totalMs: Date.now() - startedAt }
|
||||
}
|
||||
}
|
||||
if (!terms.length) {
|
||||
const messageLoadStartedAt = Date.now()
|
||||
const metadata = this.searchByMetadata(query, conversationIds, senderIds)
|
||||
@@ -475,6 +567,7 @@ export class KnowledgeStore {
|
||||
timings: {
|
||||
...emptyKnowledgeSearchTimings(),
|
||||
ftsMs,
|
||||
shortTermSearchMs,
|
||||
messageLoadMs,
|
||||
chunkExpandMs,
|
||||
rankingMs,
|
||||
@@ -703,7 +796,9 @@ export class KnowledgeStore {
|
||||
|
||||
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
|
||||
const startedAt = Date.now()
|
||||
const statusStartedAt = Date.now()
|
||||
const status = this.getSearchStatus()
|
||||
const statusMs = Date.now() - statusStartedAt
|
||||
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
|
||||
const voiceStartedAt = Date.now()
|
||||
const voiceCoverage = this.getVoiceCoverage(query)
|
||||
@@ -720,6 +815,7 @@ export class KnowledgeStore {
|
||||
totalMs: workerExecutionMs,
|
||||
globalCountMs: statsRefreshMs,
|
||||
voiceCoverageMs,
|
||||
statusMs,
|
||||
workerExecutionMs
|
||||
},
|
||||
conversationRetrieval: measured?.conversationRetrieval,
|
||||
@@ -807,6 +903,15 @@ export class KnowledgeStore {
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
|
||||
ON knowledge_messages (conversation_id, create_time);
|
||||
-- 跨会话 lexical probe 的短词回退路径是「全表 LIKE + ORDER BY create_time DESC LIMIT k」。
|
||||
-- 没有这个索引时 SQLite 只能 SCAN + TEMP B-TREE,代价随表增长线性上升;
|
||||
-- 有了它就能按时间倒序走索引并提前终止(同一 ORDER BY / 同一 LIMIT,结果集完全一致),
|
||||
-- 降到毫秒级。它不改变任何检索语义,只是让同一条 SQL 有可用的访问路径。
|
||||
CREATE INDEX IF NOT EXISTS knowledge_messages_time
|
||||
ON knowledge_messages (create_time);
|
||||
-- 语音覆盖聚合按 (kind, conversation_id[, create_time]) 过滤;没有它就只能全表扫。
|
||||
CREATE INDEX IF NOT EXISTS knowledge_messages_kind_conversation
|
||||
ON knowledge_messages (kind, conversation_id);
|
||||
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
rowid INTEGER PRIMARY KEY,
|
||||
chunk_id TEXT NOT NULL UNIQUE,
|
||||
@@ -832,10 +937,18 @@ export class KnowledgeStore {
|
||||
state TEXT NOT NULL,
|
||||
high_water_time INTEGER,
|
||||
indexed_message_count INTEGER NOT NULL DEFAULT 0,
|
||||
complete_snapshot INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`)
|
||||
const stateColumns = new Set(asRows(this.database.prepare('PRAGMA table_info(knowledge_index_state)').all()).map((row) => String(row.name)))
|
||||
if (!stateColumns.has('complete_snapshot')) {
|
||||
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0')
|
||||
}
|
||||
if (!stateColumns.has('source_high_water_time')) {
|
||||
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN source_high_water_time INTEGER')
|
||||
}
|
||||
const messageColumns = new Set(
|
||||
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
|
||||
String(row.name)
|
||||
@@ -924,7 +1037,24 @@ export class KnowledgeStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changedAt < 0) return { chunkCount: 0, updatedChunks: 0 }
|
||||
if (changedAt < 0) {
|
||||
// 内容没有变化 → 不必重建分片。但仍然要记下这一遍扫到的**源侧**边界,
|
||||
// 否则下一次增量 pass 又会因为缺少标记而重读这个会话(永远无法跳过)。
|
||||
//
|
||||
// 单调推进:checkpoint 只应该前进。让一个"看起来更旧"的值覆盖它,会把已经追到最新的
|
||||
// 会话重新打回"有新消息",于是每一遍都白读一次,还会让 freshness 误判回退。
|
||||
if (conversation.sourceHighWaterTime !== undefined) {
|
||||
this.database
|
||||
.prepare(
|
||||
`UPDATE knowledge_index_state
|
||||
SET source_high_water_time = MAX(COALESCE(source_high_water_time, 0), ?),
|
||||
updated_at = ?
|
||||
WHERE conversation_id = ?`
|
||||
)
|
||||
.run(conversation.sourceHighWaterTime, Date.now(), conversation.conversationId)
|
||||
}
|
||||
return { chunkCount: 0, updatedChunks: 0 }
|
||||
}
|
||||
|
||||
const rebuildStart = Math.max(0, changedAt - chunker.overlapMessages)
|
||||
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
|
||||
@@ -935,7 +1065,9 @@ export class KnowledgeStore {
|
||||
chunker.version,
|
||||
'indexing',
|
||||
null,
|
||||
normalized.length
|
||||
normalized.length,
|
||||
null,
|
||||
conversation.completeSnapshot
|
||||
)
|
||||
await this.writeMessageLedger(
|
||||
conversation.conversationId,
|
||||
@@ -982,7 +1114,10 @@ export class KnowledgeStore {
|
||||
chunker.version,
|
||||
'ready',
|
||||
highWater,
|
||||
normalized.length
|
||||
normalized.length,
|
||||
null,
|
||||
conversation.completeSnapshot,
|
||||
conversation.sourceHighWaterTime ?? null
|
||||
)
|
||||
this.database.exec('COMMIT')
|
||||
return { chunkCount: chunks.length, updatedChunks: chunks.length }
|
||||
@@ -1122,22 +1257,37 @@ export class KnowledgeStore {
|
||||
state: string,
|
||||
highWater: number | null,
|
||||
messageCount: number,
|
||||
error: string | null = null
|
||||
error: string | null = null,
|
||||
completeSnapshot = false,
|
||||
/**
|
||||
* 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。
|
||||
* 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉,
|
||||
* 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。
|
||||
*/
|
||||
sourceHighWater: number | null = null
|
||||
): void {
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO knowledge_index_state (
|
||||
conversation_id, account_id, chunker_version, state, high_water_time,
|
||||
indexed_message_count, last_error, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
account_id = excluded.account_id,
|
||||
chunker_version = excluded.chunker_version,
|
||||
state = excluded.state,
|
||||
high_water_time = excluded.high_water_time,
|
||||
indexed_message_count = excluded.indexed_message_count,
|
||||
complete_snapshot = excluded.complete_snapshot,
|
||||
last_error = excluded.last_error,
|
||||
updated_at = excluded.updated_at`
|
||||
updated_at = excluded.updated_at,
|
||||
-- 单调推进 + 保留 NULL 语义:新值为 NULL 时保持旧值;否则取两者较大者。
|
||||
-- 若退化成写 0,readSourceHighWaterMarks() 的 IS NOT NULL 就会把该会话
|
||||
-- 当成"有 checkpoint 但等于 0",从而每遍都误走 backfill 全量读。
|
||||
source_high_water_time = CASE
|
||||
WHEN excluded.source_high_water_time IS NULL THEN knowledge_index_state.source_high_water_time
|
||||
ELSE MAX(COALESCE(knowledge_index_state.source_high_water_time, 0), excluded.source_high_water_time)
|
||||
END`
|
||||
)
|
||||
.run(
|
||||
conversationId,
|
||||
@@ -1146,11 +1296,33 @@ export class KnowledgeStore {
|
||||
state,
|
||||
highWater,
|
||||
messageCount,
|
||||
completeSnapshot ? 1 : 0,
|
||||
error,
|
||||
Date.now()
|
||||
Date.now(),
|
||||
sourceHighWater
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个会话「已经索引到源数据的哪个时刻」(epoch ms)。
|
||||
*
|
||||
* 增量 pass 用它判断哪些会话真的需要重新读取:Session 行的 `last_timestamp` 不晚于这个值
|
||||
* 就说明没有新消息,可以直接跳过(不读 WCDB、不写索引)。
|
||||
*/
|
||||
readSourceHighWaterMarks(): Record<string, number> {
|
||||
const marks: Record<string, number> = {}
|
||||
for (const row of asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
'SELECT conversation_id, source_high_water_time FROM knowledge_index_state WHERE source_high_water_time IS NOT NULL'
|
||||
)
|
||||
.all()
|
||||
)) {
|
||||
marks[String(row.conversation_id)] = Number(row.source_high_water_time)
|
||||
}
|
||||
return marks
|
||||
}
|
||||
|
||||
private readMeta(key: string): string | null {
|
||||
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
|
||||
| DbRow
|
||||
@@ -1184,6 +1356,43 @@ export class KnowledgeStore {
|
||||
this.refreshStatsSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索热路径专用的快照读取(**不做全表聚合**)。
|
||||
*
|
||||
* 分开的原因:`markStatsStale()` 在**每次** `index()` 调用时都会执行,而
|
||||
* `KnowledgeSearchService.indexAccount` 是**逐会话**调用 `index()` 的,所以一遍后台 pass
|
||||
* 进行中 `stats_state` 几乎永远是 `'stale'`,pass 被中断后更是会一直留在 `'stale'`。
|
||||
* 如果每次搜索都因此重做三次全表聚合(messages / chunks / voice),单个 probe 的代价就是
|
||||
* 数十秒级,交互查询会被卡住。
|
||||
*
|
||||
* 规则:只有在快照**从未建立**(首库)或**明显与真实数据不符**(快照说 0 个分片、
|
||||
* 但库里确实有分片 → 会把可查询的库误报成 unavailable)时才刷新。其余情况沿用上一次完整
|
||||
* pass 写下的结论:数字可能略旧,但不会把可查询的库说成不可用,也不会把交互查询卡在
|
||||
* 全表聚合上。
|
||||
*/
|
||||
private ensureStatsSnapshotForQuery(): void {
|
||||
const state = this.readMeta('stats_state')
|
||||
if (state === 'fresh') return
|
||||
if (state === null) {
|
||||
this.refreshStatsSnapshot()
|
||||
return
|
||||
}
|
||||
if (this.readStatNumber('stats_chunk_count') === 0 && this.hasAnyChunk()) {
|
||||
this.refreshStatsSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/** 只探一行,用于避免把"有分片但快照过期"的库报成不可用。 */
|
||||
private hasAnyChunk(): boolean {
|
||||
try {
|
||||
return Boolean(
|
||||
this.database.prepare('SELECT 1 AS present FROM knowledge_chunks LIMIT 1').get()
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private markStatsStale(): void {
|
||||
this.writeMeta('stats_state', 'stale')
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type WorkerResult =
|
||||
| KnowledgeCapacityPreflight
|
||||
| KnowledgeSearchResult
|
||||
| KnowledgeRuntimeStatus
|
||||
| { marks: Record<string, number> }
|
||||
| { removed: true }
|
||||
type PendingRequest = {
|
||||
resolve: (result: WorkerResult) => void
|
||||
@@ -36,6 +37,14 @@ export class KnowledgeWorkerHost {
|
||||
private child: ChildProcess | null = null
|
||||
private childStartedAt = 0
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
/**
|
||||
* 当前在跑的索引请求 id。
|
||||
*
|
||||
* 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 ——
|
||||
* 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能
|
||||
* 精确地只中止索引,而**不会**影响任何并发进行的查询请求。
|
||||
*/
|
||||
private activeIndexRequestId: string | null = null
|
||||
|
||||
constructor(private readonly workerPath: string) {}
|
||||
|
||||
@@ -43,7 +52,9 @@ export class KnowledgeWorkerHost {
|
||||
payload: KnowledgeIndexRequest,
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
): Promise<KnowledgeIndexResult> {
|
||||
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult>
|
||||
return this.request('index', payload, onProgress, (requestId) => {
|
||||
this.activeIndexRequestId = requestId
|
||||
}) as Promise<KnowledgeIndexResult>
|
||||
}
|
||||
|
||||
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
|
||||
@@ -58,6 +69,21 @@ export class KnowledgeWorkerHost {
|
||||
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
|
||||
}
|
||||
|
||||
/** 每个会话已经索引到的源侧时刻;用于增量 pass 跳过没有变化的会话。 */
|
||||
highWaterMarks(payload: KnowledgeStatusRequest): Promise<Record<string, number>> {
|
||||
return this.request('highWater', payload as unknown as KnowledgeWorkerRequest['payload']).then(
|
||||
(result) => ('marks' in result ? result.marks : {})
|
||||
)
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
||||
async cancelActiveIndex(): Promise<boolean> {
|
||||
const target = this.activeIndexRequestId
|
||||
if (!target) return false
|
||||
await this.cancel(target)
|
||||
return true
|
||||
}
|
||||
|
||||
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
|
||||
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
|
||||
}
|
||||
@@ -81,13 +107,15 @@ export class KnowledgeWorkerHost {
|
||||
private request(
|
||||
type: KnowledgeWorkerRequest['type'],
|
||||
payload: KnowledgeWorkerRequest['payload'],
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void,
|
||||
onRequestId?: (requestId: string) => void
|
||||
): Promise<WorkerResult> {
|
||||
const hadWorker = Boolean(this.child?.connected)
|
||||
const child = this.ensureChild()
|
||||
const requestId = randomUUID()
|
||||
const sentAt = Date.now()
|
||||
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
|
||||
onRequestId?.(requestId)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(requestId, {
|
||||
resolve,
|
||||
@@ -145,6 +173,7 @@ export class KnowledgeWorkerHost {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) return
|
||||
this.pending.delete(requestId)
|
||||
if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null
|
||||
if (error) pending.reject(error)
|
||||
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
|
||||
else pending.reject(new Error('Knowledge worker returned no result'))
|
||||
|
||||
@@ -114,6 +114,7 @@ async function handleSearch(
|
||||
evidence: [],
|
||||
indexedMessageCount: 0,
|
||||
indexedChunkCount: 0,
|
||||
indexLatestAt: null,
|
||||
timings: emptyKnowledgeSearchTimings()
|
||||
},
|
||||
workerReceivedAt,
|
||||
@@ -153,7 +154,9 @@ async function handleStatus(
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 0,
|
||||
walBytes: 0,
|
||||
shmBytes: 0
|
||||
shmBytes: 0,
|
||||
indexLatestAt: null,
|
||||
sourceLatestAt: null
|
||||
}
|
||||
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||
return
|
||||
@@ -166,6 +169,24 @@ async function handleStatus(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个会话「已经索引到源数据的哪个时刻」。
|
||||
* 增量 pass 靠它决定哪些会话可以整段跳过(见 `KnowledgeStore.readSourceHighWaterMarks`)。
|
||||
*/
|
||||
async function handleHighWater(
|
||||
request: KnowledgeWorkerRequest,
|
||||
payload: KnowledgeStatusRequest
|
||||
): Promise<void> {
|
||||
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||
const marks = existsSync(path) ? getStore(payload).readSourceHighWaterMarks() : {}
|
||||
send({
|
||||
version: 1,
|
||||
type: 'result',
|
||||
requestId: request.requestId,
|
||||
payload: { marks } as unknown as KnowledgeWorkerResponse['payload']
|
||||
})
|
||||
}
|
||||
|
||||
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
||||
try {
|
||||
if (request.type === 'cancel') {
|
||||
@@ -201,6 +222,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
||||
await handleStatus(request, request.payload as KnowledgeStatusRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'highWater') {
|
||||
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'index') {
|
||||
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||
return
|
||||
|
||||