Compare commits

...
80 Commits
Author SHA1 Message Date
Wxw-Gu e6db4de711 chore: 提升版本号 修改文案, 以及 macos 首次登录页面文案修改 2026-09-15 16:47:56 +08:00
Wxw-Gu adff6021fc test: 修改测试用例 2026-09-15 15:32:40 +08:00
qingmao 75004375df Merge pull request #41 from Wxw-Gu/codex/develop-xkey-4-1-13
Codex/develop xkey 4 1 13
2026-09-15 15:23:47 +08:00
qingmao 75de616527 Merge pull request #42 from yimizilu/fix/http-image-media-identifiers
fix: prevent HTTP image media ID collisions across conversations
2026-09-15 14:57:26 +08:00
Wxw-Gu f5158fd3e4 feat: 完全支持 mac Intel 环境
抽离 Intel 专用包、connector 与跨架构语音库
修改 publish 脚本,发布先落草稿
修复重装依赖后 Electron 二进制缺失
2026-09-15 14:12:00 +08:00
Wxw-Gu ed4c21a68f docs: 新增提交规范 2026-09-15 11:41:53 +08:00
Wxw-Gu f35ca1778a docs: 移除已下线的防撤回说明 2026-09-15 11:26:11 +08:00
Wxw-Gu 21b2b57d28 feat: 隐藏设置里的防撤回入口,历史开启强制收敛为 false 2026-09-15 11:26:06 +08:00
Wxw-Gu 67dc45819e feat: 修改社区模板市场入口 放到日报二级菜单下, 增加跳转github 2026-09-15 11:03:00 +08:00
Wxw-Gu ff212d594c fix: 增加手动复制退群信息 以及设置页面文案 2026-09-15 10:36:09 +08:00
yimizilu 353df30ff8 fix: align media errors and serialize bigint server IDs 2026-09-15 08:28:19 +08:00
yimizilu 4f8435d0e2 fix: scope HTTP image media handles to database and conversation 2026-09-15 08:03:05 +08:00
Wxw-Gu 389a853560 fix: 日报截图窗口隐藏化、搜索缓存兼容读取与定时日报单测等待窗口 2026-09-14 22:25:55 +08:00
wuyouMaster c437be349e fix: preserve legacy macOS key capture 2026-09-14 18:44:15 +08:00
wuyouMaster 488966d771 fix: expose macOS login-time key detection 2026-09-14 18:20:20 +08:00
wuyouMaster 00095ae627 feat: add WeChat 4.1.13 macOS key capture 2026-09-14 18:20:11 +08:00
Wxw-Gu ffbe78f74a test: 测试用例 2026-09-14 18:09:43 +08:00
Wxw-Gu 7e9a79486e test: 测试用例 2026-09-14 17:41:55 +08:00
Wxw-Gu 4191d4d5f5 test: 固定本地时间标签的测试时区 2026-09-14 17:17:23 +08:00
Wxw-Gu 29c1166eca fix: 按本机时区和可变模板目录收口测试 2026-09-14 17:11:45 +08:00
Wxw-Gu bf78055b69 fix: 收口发布候选版本的时间和配置问题 2026-09-14 16:58:53 +08:00
Wxw-Gu 550e3b104c feat: 抽离保留进程功能到设置里 2026-09-14 16:44:53 +08:00
Wxw-Gu cf9ea8a83b feat: 优化 macOS 微信发送消息能力
参考 wechat_chatter PR #36:
https://github.com/yincongcyincong/wechat_chatter/pull/36

补充 WeChat 4.1.11.53 的 GetService / CdnManager 地址,
并适配绑定后的发送能力检测逻辑。
2026-09-14 15:59:08 +08:00
qingmao 5efae86fcf Merge pull request #35 from huangzhenhao90/fix/opencode-go-session-header
fix(ai): 修复 OpenCode Go 缺少会话 header 导致日报生成失败
2026-09-14 10:05:18 +08:00
电摇小子 70c55442f3 feat: 支持Mac Intel获取密钥, 提供完整支持 2026-09-13 20:08:40 +08:00
Wxw-Gu 9288256a5e feat: 支持mac intel 2026-09-13 13:39:03 +08:00
Wxw-Gu e5bd162a01 fix: 优化微信发送日志展示 2026-09-13 11:22:47 +08:00
Wxw-Gu cef1ad493c feat: 实时刷新档案会话列表
收到新消息后增量更新会话摘要和时间

自动按最新消息重新排序并保留当前选择状态
2026-09-13 11:15:30 +08:00
Wxw-Gu add16885fe fix: 修复导出日期、语音和联系人名字问题
- 导出的 HTML:点月份先看日期,选中日期后才跳到对应消息。
- 语音转文字:不同账号、不同聊天里的语音不会再串到一起。
- 联系人名字:正常昵称显示不变;昵称异常时使用可见的备注或账号,导出文件名也能正常使用。
2026-09-13 11:15:30 +08:00
Wxw-Gu ab3b3731b7 feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路
完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论
基于会话实现真正的增量追新与历史补齐
支持后台同步、取消恢复、重启续传以及同步期间继续查询
优化知识库跨会话检索、同步状态、进度展示和侧栏布局
2026-09-11 17:44:48 +08:00
Wxw-Gu 0c4932d740 feat: 完善agent查询和检索 2026-09-11 10:32:21 +08:00
Wxw-Gu fe8619d56d fix: 完善查询agent时间与检索契约
增加调试与诊断
2026-09-10 17:45:12 +08:00
Wxw-Gu 376ea79ff6 feat: 优化问问微信工具规划 2026-09-10 15:35:12 +08:00
Wxw-Gu dc6d1eb6ac fix: 修复查询工具消息引用与参数校验 2026-09-10 11:10:47 +08:00
Wxw-Gu 532e135406 chore: 更换main分支二维码 2026-09-10 09:38:54 +08:00
Wxw-Gu 7ae0d63a7f feat: 新增本地查询模型调用 2026-09-10 09:35:42 +08:00
Wxw-Gu c759bcd30e feat: 退群监控增加重新发送功能 2026-09-09 17:58:14 +08:00
Wxw-Gu 0bad419150 feat: 新增本地微信查询工具 API 2026-09-09 16:52:39 +08:00
Wxw-Gu facc292443 feat: 完善问问微信查询理解与检索链路 2026-09-09 16:51:31 +08:00
Wxw-Gu 7554de7d72 feat: 新增日报生产片段 UI 契约与视觉保护 2026-09-09 11:17:29 +08:00
Wxw-Gu 1bc66e33fd docs: README 2026-09-08 17:58:30 +08:00
Wxw-Gu 976aac32c5 feat: 日报新增模板市场,支持社区模板安装与使用 2026-09-08 11:37:38 +08:00
zhenhao 3634f49a48 fix(ai): send stable session headers for OpenCode Go 2026-09-07 15:07:22 +08:00
Wxw-Gu 1156362c3c test: 完善 2.3.0 跨平台测试与 CI 稳定性
- 修复 Windows 与 macOS 单元测试环境差异\n- 完善组件、集成与 Electron E2E 测试\n- 修复异步等待、平台路径和环境依赖问题\n- 统一 E2E 窗口尺寸与 Visual 测试策略\n- 提升 GitHub Actions 跨平台 CI 稳定性
2026-09-05 10:38:41 +08:00
Wxw-Gu 60528ffe53 Merge branch 'develop' of github.com:Wxw-Gu/TraceMemo into develop 2026-09-04 17:52:53 +08:00
Wxw-Gu f28480ffbe docs: 整理文档 2026-09-04 17:52:49 +08:00
qingmao 8e784d8125 Merge pull request #33 from Lazy-CZ/fix/export-audio-fix
fix: 修复批量导出 wav 音频语速变快、音调升高(采样率不匹配)问题
2026-09-04 16:14:35 +08:00
yy2257 3489535750 test: 恢复表情导出保护并补充语音回归测试 2026-09-04 15:55:22 +08:00
DESKTOP-QMBPBO5\Lazy 5e268cd8f4 fix: 修复导出的音频文件变调、变速的问题 2026-09-04 15:09:06 +08:00
Wxw-Gu 454f449c41 feat: 统一手动发送入口为文字转语音
- 移除普通文本、图片和本地语音的手动发送入口
- 保留文字转语音的生成、试听和发送能力
2026-09-04 15:05:04 +08:00
电摇小子 517e650942 fix: 修复用户已加载的历史记录数量 2026-09-04 10:45:41 +08:00
电摇小子 21e299942b refactor: 重构一下档案搜索功能 支持拼音微信号等 2026-09-03 23:38:12 +08:00
Wxw-Gu bde3bc537f Merge branch 'develop' of github.com:Wxw-Gu/TraceMemo into develop 2026-09-03 18:12:34 +08:00
Wxw-Gu 719ffa0a24 feat: 优化档案搜索与头像
新增退群监控开关
发送限速
2026-09-03 18:12:29 +08:00
qingmao 8fa4dd50e7 Merge pull request #31 from njueeRay/fix/windows-v4-image-key-derivation
fix: derive Windows image keys from local metadata
2026-09-03 17:53:15 +08:00
电摇小子 3462d49516 perf: 重构退群监控,持久化快照并降低 CPU 占用
- 持久化群成员
- 支持 TraceMemo 重启后恢复离线期间的退群检测
- 使用 Batch Membership 替代逐群成员查询
- 降低大量群聊监控时的 CPU 和数据库查询开销
2026-09-03 01:50:34 +08:00
Ray 7ef991aa67 fix: derive Windows image keys from local metadata 2026-09-03 01:00:37 +08:00
电摇小子 4d758b2aca Merge branch 'main' into develop 2026-09-02 19:45:45 +08:00
Wxw-Gu 27f4108cf9 feat: 统一发送能力 接入退群通知与定时日报 2026-09-02 19:43:30 +08:00
电摇小子 4075e52eec Merge branch 'main' of https://github.com/Wxw-Gu/WechatExplorer 2026-09-02 19:42:24 +08:00
电摇小子 f7d619292b chore: 更新二维码 2026-09-02 19:10:13 +08:00
Wxw-Gu e1f4ec79dd feat: 建立发送网关与动作审计 2026-09-02 18:00:39 +08:00
Wxw-Gu 76e9e683c4 chore: 统一日报时间逻辑 2026-09-02 16:02:05 +08:00
Wxw-Gu 7c8b45a24a feat: 修复定时日报错误通知微信机器人功能
增加定时日报debug
修复定时日报历史头像显示并区分定时日报标题
2026-09-02 15:01:03 +08:00
Wxw-Gu 1c8bbae1f0 fix: 增加thinking disabled
修改退群监控模板
定时日报保存日报历史
2026-09-01 11:40:14 +08:00
电摇小子 25b72c3d76 feat: 定时日报支持Windows 2026-09-01 11:36:15 +08:00
Wxw-Gu f65dc4ac32 feat: 刷新消息列表时间改为350ms 2026-08-31 23:27:22 +08:00
Wxw-Gu e59332b514 feat: 增加退群监控功能 2026-08-31 21:06:53 +08:00
Wxw-Gu 6cbf9af662 feat: mac语音转换silk格式修复 2026-08-31 10:55:52 +08:00
电摇小子 5acfa14475 feat: 增加windows发送能力
抽离功能到设置页面
抽离文字转语音功能
2026-08-31 02:44:24 +08:00
Wxw-Gu 698251796f feat: 增加一个查找语音文件 2026-08-28 15:04:37 +08:00
Wxw-Gu a007e110ac feat: 扩展 Skill 支持定时日报操作 2026-08-27 20:27:51 +08:00
Wxw-Gu 7cf043e3c3 feat: 完善定时日报 2026-08-27 19:03:07 +08:00
Wxw-Gu 949e6bc868 feat: 新增定时日报与微信发送能力底座 2026-08-27 17:08:09 +08:00
Wxw-Gu 62abd872ee Merge branch 'develop' of https://github.com/Wxw-Gu/WechatExplorer into develop 2026-08-27 14:45:48 +08:00
Wxw-Gu 0d68217dda fix: 修复转换微信语音发送逻辑 2026-08-27 14:43:09 +08:00
qingmao ee5f50db83 Merge pull request #25 from Wxw-Gu/nanin/develop
修复部分自定义表情导出后图裂的问题
2026-08-27 09:55:58 +08:00
qingmao b26d4dd1a7 Merge pull request #24 from ZipperWang/main
添加response API支持,添加流式传输支持
2026-08-26 17:29:54 +08:00
Zipper_Wang 8f9a0c44da Merge upstream origin/main 2026-08-26 16:49:47 +08:00
Zipper_Wang 5ff67edaef feat: 支持 OpenAI Responses API 2026-08-25 17:15:07 +08:00
307 changed files with 40320 additions and 3816 deletions
+3
View File
@@ -5,6 +5,9 @@ VITE_DB_KEY=
# Set to true/1/yes/on for local development. Default is disabled. # Set to true/1/yes/on for local development. Default is disabled.
VITE_AUTO_LOGIN=false 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) # AI API Configuration (Optional, can be entered in UI)
# 注意:发布版本不再自动读取以下环境变量。 # 注意:发布版本不再自动读取以下环境变量。
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填, # 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
+3 -2
View File
@@ -31,10 +31,9 @@ jobs:
cache-dependency-path: services/wechat-connector/go.sum cache-dependency-path: services/wechat-connector/go.sum
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install
- name: Install Playwright Chromium - name: Install Playwright Chromium
if: runner.os == 'macOS'
run: pnpm exec playwright install chromium run: pnpm exec playwright install chromium
- name: Type check - name: Type check
@@ -64,6 +63,8 @@ jobs:
WXE_E2E_CLOSE_DELAY_MS: 0 WXE_E2E_CLOSE_DELAY_MS: 0
- name: Platform visual regression - 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 run: pnpm exec playwright test tests/e2e/visual.spec.ts
env: env:
WXE_E2E_CLOSE_DELAY_MS: 0 WXE_E2E_CLOSE_DELAY_MS: 0
+9 -8
View File
@@ -1,5 +1,6 @@
node_modules node_modules
*.tsbuildinfo *.tsbuildinfo
electron.vite.config.[0-9]*.mjs
dist dist
out out
.env .env
@@ -13,15 +14,15 @@ resources/connectors/wechat/
resources/connectors/wechat-personal/ resources/connectors/wechat-personal/
.omc .omc
.codex/ .codex/
services/share-card-worker/.wrangler/
services/share-card-worker/wrangler.local.jsonc
skills-lock.json skills-lock.json
docs/design/ *__screenshots__
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md /AGENTS.md
AGENTS.md /CLAUDE.md
/.claude/
/.workbuddy/
/.ai-local/
findings.md findings.md
progress.md progress.md
task_plan.md task_plan.md
.agents
*__screenshots__
+112
View File
@@ -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.
+87 -317
View File
@@ -4,12 +4,9 @@
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" /> <img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
</p> </p>
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2> <h2 align="center">把微信里的信息,记住、理解、监控,并在需要时行动</h2>
<p align="center"> <p align="center">本地优先的微信数据、AI 分析与自动化工作台</p>
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
查看聊天 · 找回信息 · AI 问答 · 微信群聊总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
</p>
<p align="center"> <p align="center">
<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/stars/Wxw-Gu/TraceMemo?style=for-the-badge" alt="GitHub stars" />
@@ -23,60 +20,71 @@
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a> <a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
· ·
<a href="./docs/README.md"><b>完整文档</b></a> <a href="./docs/README.md"><b>完整文档</b></a>
·
<a href="./docs/concepts/how-it-works.md"><b>TraceMemo 如何工作</b></a>
</p> </p>
<p align="center"> <p align="center">
<img src="./public/software-1.png" alt="TraceMemo 主界面" /> <img src="./public/日报.png" alt="TraceMemo 主界面" />
</p> </p>
<p align="center"> <p align="center">
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" /> <img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
</p> </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> TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数据、AI 分析与自动化工作台,把聊天变成可浏览、可搜索、可理解、可追溯的信息。
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、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,原来的微信 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 - Reader Skill
- Agent 接入 - Agent 接入
- 多种聊天记录导出能力 - 多种聊天记录导出能力
- 退群监控
- 文字转语音
- 持续监控自动化能力
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。 群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。 这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。 感谢每一位使用、关注和反馈过的人。
</details> </details>
@@ -124,298 +135,54 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
## 从你的任务开始 ## 从你的任务开始
| 我现在想做什么 | 在应用里打开 | 需要准备什么 | | 想做什么 | 使用入口 |
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ | | ---------------------------------- | ----------------------------- |
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI | | 找记得原文或关键词的消息 | 档案搜索 |
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 | | 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 | | 长期跨群查询历史 | 本地知识库 |
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 | | 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
| 把群聊日报生成微信分享卡片(实验性) | [微信分享卡片](./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 提问 | Agent Hub |
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI | | 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
| 让 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 首次连接可能需要按页面提示完成额外授权。
---
## 快速开始 ## 快速开始
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载安装包。 1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载对应平台的安装包。
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。 2. 启动应用,按“第一次使用”页面选择微信数据目录并完成连接。
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。 3. 打开“档案”,确认联系人和消息已加载后开始搜索。
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。 4. 需要 AI 时,在“设置 → AI 模型”添加并测试 Provider。
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。 详细步骤见[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)。
当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。
迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
---
## 配置 AI
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。
应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
---
## 文档 ## 文档
- [文档首页](./docs/README.md) - [用户指南](./docs/README.md#用户指南)
- [第一次使用](./docs/user-guide/getting-started.md) - [AI / Knowledge](./docs/README.md#ai-与知识库)
- [聊天档案与搜索](./docs/user-guide/chat-archive.md) - [Monitor / Automation](./docs/README.md#日报与自动化)
- [AI 查找聊天信息](./docs/user-guide/ai-search.md) - [Agent / API](./docs/README.md#agent--api)
- [本地知识库](./docs/user-guide/knowledge.md) - [开发文档](./docs/development/overview.md)
- [群聊日报](./docs/user-guide/report.md) - [隐私与安全](./docs/user-guide/privacy.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)维护。
## 本地开发 ## 支持平台
需要 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 **提 PR 请基于 `develop` 拉新分支,并把 PR 的目标分支设为 `develop`** —— 指向 `main` 的 PR 会被直接关闭。
pnpm typecheck
pnpm test:unit
pnpm test:component
pnpm test:integration
pnpm test:e2e:build
```
完整说明:[开发、测试与构建](./docs/development/overview.md) 分支流程、提交信息风格、PR 前自检,以及**给 AI Agent 的硬性规则**,都在[参与贡献指南](./CONTRIBUTING.md)。
---
## 支持与反馈
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
提交 Issue 时请提供:
- 操作系统
- 微信版本
- TraceMemo 版本
- 复现步骤
- 已遮挡敏感信息的截图
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
---
## 许可说明
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件。
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
---
## 致谢 ## 致谢
@@ -423,18 +190,21 @@ TraceMemo 的诞生离不开开源社区中许多优秀项目的工作。
### 特别感谢 WeFlow ### 特别感谢 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)** - **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
- 提供了数据库解析相关思路。 - 提供了数据解析相关思路。
- **[chatlog](https://github.com/sjzar/chatlog)** - **[chatlog](https://github.com/sjzar/chatlog)**
- 提供了数据处理方面的参考。 - 提供了数据处理方面的参考。
- **[wechat_chatter](https://github.com/yincongcyincong/wechat_chatter)**
- 提供了发送方面的参考。
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。 感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
--- ---
+47 -42
View File
@@ -1,64 +1,69 @@
# TraceMemo 文档 # TraceMemo 文档
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。 文档按产品任务和使用场景组织。需要集成或开发时,再看 Agent、概念和开发文档。
## 从这里开始 ## 档案与搜索
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。 - [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。 - [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。 - [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
## 你可以完成的任务 ## AI 与知识库
- [建立本地知识库](./user-guide/knowledge.md) - [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
- [生成群聊日报和总结](./user-guide/report.md) - [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md) - [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md) - [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
- [语音转文字](./user-guide/voice.md)
- [导出聊天档案](./user-guide/export.md)
- [防撤回](./user-guide/recall-protection.md)
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
- [数据、隐私与安全](./user-guide/privacy.md)
- [常见问题与排查](./user-guide/troubleshooting.md)
## 如果你想了解 AI 为什么这样回答 ## 日报与自动化
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。 - [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。 - 定时日报会依次生成报告、保存 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) - [开发、测试与构建](./development/overview.md)
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.md)
- [本地启动排障](./development/local-startup-troubleshooting.md) - [本地启动排障](./development/local-startup-troubleshooting.md)
- [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md) - [macOS 数据访问说明](./platform/macos.md)
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.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 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
+88 -4
View File
@@ -36,7 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` | | GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 | | GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` | | GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
| GET | `/api/v1/media/{messageId}` | 获取图片消息的二进制资源 | 使用 `/chatlog` 返回的图片消息 `id` | | GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` | | GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` | | GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON | | POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
@@ -85,9 +85,9 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
- `200`:请求成功; - `200`:请求成功;
- `401`:缺少、错误或已失效的 Bearer Token; - `401`:缺少、错误或已失效的 Bearer Token;
- `400`:参数或 JSON 请求体无效; - `400`:参数或 JSON 请求体无效;
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片; - `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
- `403`:浏览器 Origin 不在允许的 loopback 列表; - `403`:浏览器 Origin 不在允许的 loopback 列表;
- `404`:端点、会话或群聊不存在; - `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
- `503`:数据库或 Agent Hub 尚未就绪; - `503`:数据库或 Agent Hub 尚未就绪;
- `500`:服务端处理或报告渲染失败。 - `500`:服务端处理或报告渲染失败。
@@ -102,13 +102,97 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
"media": { "media": {
"type": "image", "type": "image",
"available": true, "available": true,
"url": "/api/v1/media/msg_xxx" "url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
} }
} }
``` ```
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。 当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。
## 与 MCP 的关系 ## 与 MCP 的关系
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。 当前实现没有把 `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` 直读聊天数据库,不受索引新鲜度影响。
+2 -2
View File
@@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如: 来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
- Knowledge 正在同步时,新的分析会被暂停; - Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围;
- 语音没有转写时,AI 可能只能看到消息类型; - 语音没有转写时,AI 可能只能看到消息类型;
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容; - 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
- 你只选择了一个群,答案不会自动代表所有聊天。 - 你只选择了一个群,答案不会自动代表所有聊天。
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。 Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
## 这不是事实保证 ## 这不是事实保证
+37
View File
@@ -19,8 +19,45 @@ flowchart LR
M[微信机器人消息] --> N[Agent Hub] M[微信机器人消息] --> N[Agent Hub]
N --> B N --> B
N --> F 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 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
## 哪些步骤在本机 ## 哪些步骤在本机
- 微信数据库读取与解析; - 微信数据库读取与解析;
@@ -38,25 +38,24 @@ go version
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。 `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 - `package.json` 里的 `pnpm.onlyBuiltDependencies: ["electron"]` 对它不起作用——上游没有脚本可执行,pnpm 无从下手;
electron_mirror=https://npmmirror.com/mirrors/electron/ - `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 ```bash
pnpm install --frozen-lockfile node scripts/ensure-electron-binary.cjs
``` ```
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例: 要换用别的镜像时,显式设置环境变量(优先于 `.npmrc`)。PowerShell 示例:
```powershell ```powershell
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/' $env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
pnpm install --frozen-lockfile node scripts/ensure-electron-binary.cjs
``` ```
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。 该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
+12 -12
View File
@@ -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/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/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/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 | | `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` | | `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/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 | | `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` | | Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` | | 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
## 文档检查 ## 文档检查
@@ -52,7 +52,7 @@ pnpm test:e2e:build
```bash ```bash
git diff --check 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”可以保留,以防用户照抄错误配置。 历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
+84
View File
@@ -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 会直接返回错误,不会回退到另一套模型配置。
+12 -1
View File
@@ -23,5 +23,16 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
## Intel 与 Apple Silicon ## 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`。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
+68 -19
View File
@@ -27,21 +27,70 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
## 端点速查 ## 端点速查
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| ---- | --------------------- | ------------------------------------------------- | | ------ | ----------------------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 | | GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 | | GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` | | GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
| GET | `/chatroom` | 群聊列表;可传 `keyword` | | GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` | | GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 | | GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 | | GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` | | GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` | | GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG | | GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 | | GET | `/scheduled-reports` | 查询全部定时日报任务 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 | | GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
| POST | `/agent/send` | 已连接机器人发送测试 | | 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`。
## 时间与上下文规则 ## 时间与上下文规则
@@ -58,7 +107,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
当 `/chatlog` 返回图片消息时: 当 `/chatlog` 返回图片消息时:
1. 如果用户只是询问图片消息是否存在,不需要获取图片。 1. 如果用户只是询问图片消息是否存在,不需要获取图片。
2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。 2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。 3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
4. 获取成功后,将图片交给当前 Agent 的视觉能力。 4. 获取成功后,将图片交给当前 Agent 的视觉能力。
5. 如果图片获取失败,明确说明无法读取图片。 5. 如果图片获取失败,明确说明无法读取图片。
@@ -71,7 +120,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
1. 调用 `/health`;必要时调用 `/current_time`。 1. 调用 `/health`;必要时调用 `/current_time`。
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。 2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。 3. 请求该消息的 `media.url`,将返回的图片交给 Vision。
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。 4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。 不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
@@ -84,7 +133,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。 - `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。 - `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
- `404`:先用 `/resolve` 确认会话标识。 - `404`:会话查询失败时先用 `/resolve` 确认会话标识;媒体请求表示标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在。
- `422`:`messageId` 无效,或消息不是可读取的图片。 - `422`:媒体标识格式错误,或消息不是可读取的图片(`NOT_IMAGE`)。
- `503`:用户还没有完成数据库连接或对应服务未就绪。 - `503`:用户还没有完成数据库连接或对应服务未就绪。
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。 - 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
+13
View File
@@ -41,6 +41,19 @@
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。 你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
## 查找过程和跳回原消息
查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。
查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。
点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息:
- 群聊来源打开的是那个群,而不是群里某个联系人;
- 会加载该消息前后的上下文,并滚动到它、短暂高亮;
- 只加载目标消息附近的一段,不会把整个会话历史全部读出来;
- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。
## 什么时候不要直接相信答案 ## 什么时候不要直接相信答案
- 来源很少,或时间范围与问题不一致; - 来源很少,或时间范围与问题不一致;
+18 -6
View File
@@ -4,7 +4,7 @@
## 选择要看的会话 ## 选择要看的会话
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。 左侧会话列表可以浏览已读取到的联系人、群聊和公众号。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。 如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
@@ -19,6 +19,22 @@
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。 关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
## 怎么找到联系人
档案搜索会综合多个身份字段匹配联系人或群聊,包括:
- 通讯录备注(remark);
- 微信昵称;
- 当前微信号;
- wxid;
- 拼音全拼和拼音首字母。
因此可以直接输入备注、昵称、微信号或拼音查找。搜索联系人和搜索消息是两步:先确认目标会话,再在会话内查关键词;记得大意但不知道原话时,改用[AI Search](./ai-search.md)。
## 文字转语音
在档案中选择当前联系人或群聊,输入文字后生成语音,试听确认后发送。这个入口只处理明确的文字转语音动作,不是任意文本、图片或本地语音文件发送器。
## 消息和媒体 ## 消息和媒体
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。 根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
@@ -27,11 +43,7 @@
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。 如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
## 可选保留撤回消息 联系人和群聊列表会尽量显示头像、备注和昵称。头像或资料缺失时不影响消息读取;这通常表示本机没有对应资源,或微信没有返回完整资料。
“设置 → 防撤回”提供一个默认关闭的可选功能。开启后,应用会尽量保留之后捕获到的撤回消息,并在气泡旁标记“消息已撤回”。它不能找回开启前已经消失或应用未捕获到的内容,也可能增加加载开销。
该功能与普通只读浏览的数据边界不同。开启前请阅读[防撤回](./recall-protection.md)。
## 保护自己不被误导 ## 保护自己不被误导
+36 -10
View File
@@ -8,12 +8,29 @@
## 1. 开始前准备 ## 1. 开始前准备
| 系统 | 已测试的微信客户端 | 需要注意 | TraceMemo 需要读取微信本地数据库。首次使用前,请确认已安装受支持的微信客户端,并按照连接页面完成数据库密钥获取。
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。 | 系统 | 微信客户端 | 首次连接说明 |
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| 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 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。 - TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
- 你需要有权访问要读取的微信账号和聊天数据。 - 你需要有权访问要读取的微信账号和聊天数据。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。 - 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
@@ -26,14 +43,16 @@
### Windows ### Windows
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。 1. 从 Releases 下载 Windows x64 的 `tracememo-<版本号>-setup.exe` 安装包。
2. 双击安装包,按向导完成安装。 2. 双击安装包,按向导完成安装。
3. 启动 TraceMemo。 3. 启动 TraceMemo。
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。 4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
### macOS ### 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 拖入“应用程序”文件夹。 2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。 3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
4. 如果系统提示应用已损坏,可在终端执行: 4. 如果系统提示应用已损坏,可在终端执行:
@@ -42,7 +61,16 @@
xattr -cr "/Applications/TraceMemo.app" 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)。 更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
@@ -106,7 +134,6 @@
- [生成群聊日报或总结](./report.md) - [生成群聊日报或总结](./report.md)
- [转写微信语音](./voice.md) - [转写微信语音](./voice.md)
- [导出聊天档案](./export.md) - [导出聊天档案](./export.md)
- [可选开启防撤回](./recall-protection.md)
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md) - [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
- [让外部 Agent 查询微信历史](../agent/overview.md) - [让外部 Agent 查询微信历史](../agent/overview.md)
@@ -142,7 +169,6 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。 - 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。 - 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。 - 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
完整边界见[数据、隐私与安全](./privacy.md)。 完整边界见[数据、隐私与安全](./privacy.md)。
+19
View File
@@ -0,0 +1,19 @@
# Intel Mac 首次获取密钥
请先备份微信本地数据。密钥获取和数据库验证都在本机完成,不要把密钥、日志或数据库发给他人。
## 开始使用
1. 启动 TraceMemo,在首次连接页面点击“重新检查环境”。
2. 如果页面显示需要准备连接环境,点击“准备环境”,按页面提示完成。
3. 打开微信,让微信停在未登录页面,不要先点击登录。
4. 回到 TraceMemo,选择微信账号,点击“开始获取密钥”。
5. 等待页面提示可以登录后,立即回到微信点击登录。
6. 等待 TraceMemo 显示“获取成功,验证数据库连接”。
获取成功后,请保存密钥。如果页面提示需要重新准备,按页面的“查看说明”操作,不要连续重试。
## 注意
- 密钥只保存在本机的安全存储中。
- 请不要删除微信本地数据目录。
+24 -1
View File
@@ -13,7 +13,30 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
- **建立本地知识库**:第一次读取当前账号的可检索聊天; - **建立本地知识库**:第一次读取当前账号的可检索聊天;
- **同步最新记录**:已有索引时,只补充新增或变化的内容。 - **同步最新记录**:已有索引时,只补充新增或变化的内容。
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。 同步在后台运行,**期间仍然可以正常提问和分析**,不会被禁用。索引还没追完时,答案会基于当前已经可用的部分给出,并在界面标注覆盖范围。
知识库卡片同时显示两组互相独立的信息:
- **规模**:已索引消息、知识片段、磁盘占用——说明索引有多大;
- **状态与本轮进度**:说明索引现在处于什么状态、这一轮同步在做什么(扫了多少、真正新增了多少、处理到第几个会话)。
`最新索引` 只表示索引已经覆盖到聊天记录的哪个时间点,**不等于**整库已经建完;进度里的计数是**本轮**的数字,不是全部历史的总数。
### 状态怎么读
| 状态 | 含义 |
| ---- | ---- |
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
### 取消和继续
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
## 账号隔离 ## 账号隔离
-3
View File
@@ -14,8 +14,6 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。 应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
## 什么时候会请求外部服务 ## 什么时候会请求外部服务
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是: 当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
@@ -58,4 +56,3 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
- 对需要外发的 AI 功能逐项确认 Provider; - 对需要外发的 AI 功能逐项确认 Provider;
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存; - 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
- 在共享电脑上退出应用并保护系统账户。 - 在共享电脑上退出应用并保护系统账户。
- 在开启防撤回前确认你接受其数据库写入、性能和清理边界,并先用微信官方方式备份重要数据。
-37
View File
@@ -1,37 +0,0 @@
# 防撤回
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
## 如何开启
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
2. 打开“设置 → 防撤回”。
3. 阅读性能和数据提示后,开启“防撤回”。
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
## 当前能做什么
- 监听应用能够识别到的后续撤回变化;
- 在本地保留必要的消息和撤回关系;
- 将已识别的原消息与撤回状态一起显示在档案中;
- 按微信账号隔离 TraceMemo 保存的恢复记录。
## 当前限制
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
## 数据写入与关闭边界
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
完整的数据边界见[数据、隐私与安全](./privacy.md)。
+16 -4
View File
@@ -6,8 +6,8 @@
典型场景包括: 典型场景包括:
- 整理今天工作群的讨论重点; - 整理今日工作群的讨论重点;
- 回顾昨天错过的决定和资源; - 回顾昨日错过的决定和资源;
- 汇总近 7 天的项目进展、待办和未解决问题; - 汇总近 7 天的项目进展、待办和未解决问题;
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。 - 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
@@ -16,7 +16,7 @@
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。 你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。 1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
2. 选择时间范围:今天、昨天或近 7 天。 2. 选择时间范围:今日、昨日或近 7 天。
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。 3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
4. 选择报告模板/内容模式并开始生成。 4. 选择报告模板/内容模式并开始生成。
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。 5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
@@ -33,10 +33,22 @@
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。 生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
## 定时日报
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
```text
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
```
生成和发送是两个阶段。当前微信发送能力不可用、未绑定或发送失败时,报告仍会保存,PNG 和执行记录也会保留;这类结果会显示为“已生成,但未发送”或“已生成,发送失败”。
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
## 让报告更可靠 ## 让报告更可靠
- 先选正确的群和时间范围; - 先选正确的群和时间范围;
- 不确定时先只选择文字消息; - 不确定时先只选择文字消息;
- 群太活跃时分成“今天”和“近 7 天”两次生成; - 群太活跃时分成“今日”和“近 7 天”两次生成;
- 看到待办和结论后回到原消息核对上下文; - 看到待办和结论后回到原消息核对上下文;
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。 - AI Provider 不可用时先检查模型配置和网络/本地服务状态。
+5 -5
View File
@@ -12,6 +12,7 @@
### macOS ### macOS
- 确认下载的构建与 Mac 处理器匹配:Apple Silicon(M 系列)用 `arm64`,Intel 用 `x64`。
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。 - 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。 - 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
@@ -25,10 +26,13 @@
2. 微信版本是否属于当前代码面向的 4.x 数据结构; 2. 微信版本是否属于当前代码面向的 4.x 数据结构;
3. 微信是否处于页面要求的登录/退出状态; 3. 微信是否处于页面要求的登录/退出状态;
4. macOS 是否完成页面要求的授权; 4. macOS 是否完成页面要求的授权;
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。 5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题;
6. macOS 上 Apple Silicon 与 Intel 使用不同的连接流程,按连接页面提示操作;两者都可以自动获取数据库密钥。
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。 重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
仍然失败时,记录 **macOS 版本、CPU 架构(Apple Silicon / Intel)、微信版本、TraceMemo 版本和页面错误提示**后 扫码 README 文档二维码进群提交消息, 或者提交 Issue。
## 连接成功但没有联系人或消息 ## 连接成功但没有联系人或消息
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。 确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
@@ -88,7 +92,3 @@ Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
5. 需要总结或自然语言理解时,AI Provider 是否可用。 5. 需要总结或自然语言理解时,AI Provider 是否可用。
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。 当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
## 防撤回没有保留消息
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
+3 -1
View File
@@ -63,4 +63,6 @@ publish:
provider: github provider: github
owner: Wxw-Gu owner: Wxw-Gu
repo: TraceMemo repo: TraceMemo
releaseType: release # 一律先上传为草稿 再到 GitHub 上手动 Publish。
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
releaseType: draft
+3 -1
View File
@@ -71,4 +71,6 @@ publish:
provider: github provider: github
owner: Wxw-Gu owner: Wxw-Gu
repo: TraceMemo repo: TraceMemo
releaseType: release # 一律先上传为草稿 再到 GitHub 上手动 Publish。
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
releaseType: draft
+2
View File
@@ -8,6 +8,8 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { input: {
index: resolve('src/main/index.ts'), 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'), voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts') knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
}, },
Binary file not shown.
+9
View File
@@ -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>
+21 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "tracememo", "name": "tracememo",
"version": "2.2.3", "version": "2.4.0",
"packageManager": "pnpm@7.33.7", "packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。", "description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [ "keywords": [
@@ -35,11 +35,16 @@
"cp:env": "node scripts/ensure-env.cjs", "cp:env": "node scripts/ensure-env.cjs",
"prepare: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: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", "prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
"start": "electron-vite preview", "start": "electron-vite preview",
"predev": "node -e \"require('electron')\"", "predev": "node scripts/ensure-electron-binary.cjs",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev", "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", "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:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"test:unit": "vitest run --config vitest.unit.config.ts", "test:unit": "vitest run --config vitest.unit.config.ts",
"test:component": "vitest run --config vitest.component.config.ts", "test:component": "vitest run --config vitest.component.config.ts",
@@ -56,15 +61,18 @@
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64", "build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
"build:native-services": "npm run build:wechat-connector", "build:native-services": "npm run build:wechat-connector",
"build": "npm run typecheck && npm run build:native-services && electron-vite build", "build": "npm run typecheck && npm run build:native-services && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs", "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir", "build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64", "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 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64", "build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && 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": "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: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:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always", "release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release", "release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always",
"release:stable": "cross-env RELEASE_TYPE=release npm run release", "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" "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
}, },
"dependencies": { "dependencies": {
@@ -90,6 +98,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"css-tree": "^3.0.1",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ffmpeg-static": "5.3.0", "ffmpeg-static": "5.3.0",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
@@ -97,10 +106,13 @@
"jsonrepair": "^3.15.0", "jsonrepair": "^3.15.0",
"koffi": "^3.1.0", "koffi": "^3.1.0",
"openai": "^6.10.0", "openai": "^6.10.0",
"parse5": "^8.0.0",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sherpa-onnx-node": "1.13.3", "sherpa-onnx-node": "1.13.3",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"unzipper": "^0.12.0",
"wechat-emojis": "^1.0.2" "wechat-emojis": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
+45 -5
View File
@@ -46,6 +46,7 @@ specifiers:
class-variance-authority: ^0.7.1 class-variance-authority: ^0.7.1
clsx: ^2.1.1 clsx: ^2.1.1
cross-env: ^10.1.0 cross-env: ^10.1.0
css-tree: ^3.0.1
electron: ^43.0.0 electron: ^43.0.0
electron-builder: ^26.0.12 electron-builder: ^26.0.12
electron-updater: ^6.6.2 electron-updater: ^6.6.2
@@ -61,6 +62,8 @@ specifiers:
jsonrepair: ^3.15.0 jsonrepair: ^3.15.0
koffi: ^3.1.0 koffi: ^3.1.0
openai: ^6.10.0 openai: ^6.10.0
parse5: ^8.0.0
pinyin-pro: ^3.26.0
postcss: ^8.5.26 postcss: ^8.5.26
prettier: ^3.7.4 prettier: ^3.7.4
qrcode: ^1.5.4 qrcode: ^1.5.4
@@ -73,6 +76,7 @@ specifiers:
tailwindcss: 3.4.17 tailwindcss: 3.4.17
tailwindcss-animate: ^1.0.7 tailwindcss-animate: ^1.0.7
typescript: ^5.9.3 typescript: ^5.9.3
unzipper: ^0.12.0
vite: ^7.2.6 vite: ^7.2.6
vitest: ^4.1.10 vitest: ^4.1.10
wechat-emojis: ^1.0.2 wechat-emojis: ^1.0.2
@@ -101,6 +105,7 @@ dependencies:
class-variance-authority: 0.7.1 class-variance-authority: 0.7.1
clsx: 2.1.1 clsx: 2.1.1
cross-env: 10.1.0 cross-env: 10.1.0
css-tree: 3.2.1
electron-updater: 6.8.9 electron-updater: 6.8.9
ffmpeg-static: 5.3.0 ffmpeg-static: 5.3.0
fs-extra: 11.3.2 fs-extra: 11.3.2
@@ -108,10 +113,13 @@ dependencies:
jsonrepair: 3.15.0 jsonrepair: 3.15.0
koffi: 3.1.0 koffi: 3.1.0
openai: 6.10.0 openai: 6.10.0
parse5: 8.0.1
pinyin-pro: 3.29.3
qrcode: 1.5.4 qrcode: 1.5.4
sherpa-onnx-node: 1.13.3 sherpa-onnx-node: 1.13.3
silk-wasm: 3.7.1 silk-wasm: 3.7.1
tailwind-merge: 3.6.0 tailwind-merge: 3.6.0
unzipper: 0.12.5
wechat-emojis: 1.0.2 wechat-emojis: 1.0.2
devDependencies: devDependencies:
@@ -3898,6 +3906,10 @@ packages:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
dev: true dev: true
/bluebird/3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
dev: false
/brace-expansion/1.1.12: /brace-expansion/1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
dependencies: dependencies:
@@ -4350,7 +4362,6 @@ packages:
dependencies: dependencies:
mdn-data: 2.27.1 mdn-data: 2.27.1
source-map-js: 1.2.1 source-map-js: 1.2.1
dev: true
/css.escape/1.5.1: /css.escape/1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
@@ -4570,6 +4581,12 @@ packages:
gopd: 1.2.0 gopd: 1.2.0
dev: true dev: true
/duplexer2/0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
dependencies:
readable-stream: 2.3.8
dev: false
/eastasianwidth/0.2.0: /eastasianwidth/0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
dev: true dev: true
@@ -4697,7 +4714,6 @@ packages:
/entities/8.0.0: /entities/8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
dev: true
/env-paths/2.2.1: /env-paths/2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
@@ -5301,6 +5317,15 @@ packages:
jsonfile: 6.2.0 jsonfile: 6.2.0
universalify: 2.0.1 universalify: 2.0.1
/fs-extra/11.3.1:
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
engines: {node: '>=14.14'}
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
dev: false
/fs-extra/11.3.2: /fs-extra/11.3.2:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
@@ -6326,7 +6351,6 @@ packages:
/mdn-data/2.27.1: /mdn-data/2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
dev: true
/merge2/1.4.1: /merge2/1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
@@ -6552,6 +6576,10 @@ packages:
semver: 7.7.3 semver: 7.7.3
dev: true dev: true
/node-int64/0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
dev: false
/node-releases/2.0.27: /node-releases/2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
dev: true dev: true
@@ -6771,7 +6799,6 @@ packages:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
dependencies: dependencies:
entities: 8.0.0 entities: 8.0.0
dev: true
/path-exists/4.0.0: /path-exists/4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
@@ -6835,6 +6862,10 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/pinyin-pro/3.29.3:
resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==}
dev: false
/pirates/4.0.7: /pirates/4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -7659,7 +7690,6 @@ packages:
/source-map-js/1.2.1: /source-map-js/1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true
/source-map-support/0.5.21: /source-map-support/0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
@@ -8204,6 +8234,16 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
/unzipper/0.12.5:
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
dependencies:
bluebird: 3.7.2
duplexer2: 0.1.4
fs-extra: 11.3.1
graceful-fs: 4.2.11
node-int64: 0.4.0
dev: false
/update-browserslist-db/1.2.2_browserslist@4.28.1: /update-browserslist-db/1.2.2_browserslist@4.28.1:
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
hasBin: true hasBin: true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 157 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13
+116 -4
View File
@@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { chmodSync, existsSync } = require('node:fs') const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs')
const { execFileSync } = require('node:child_process') const { execFileSync } = require('node:child_process')
const path = require('node:path') const path = require('node:path')
const asar = require('@electron/asar') const asar = require('@electron/asar')
const { readBinaryArchitectures } = require('./binary-arch.cjs')
const REQUIRED_RUNTIME_PACKAGES = [ const REQUIRED_RUNTIME_PACKAGES = [
'@electron-toolkit/preload', '@electron-toolkit/preload',
@@ -82,6 +83,43 @@ function normalizeBuilderArch(arch) {
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(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) { function validateAsarRuntimeDependencies(runtimeResources) {
const asarPath = path.join(runtimeResources, 'app.asar') const asarPath = path.join(runtimeResources, 'app.asar')
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`) if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
@@ -107,17 +145,87 @@ function validateReaderSkillRuntime(runtimeResources) {
return skillPath 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) { exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context) const runtimeResources = getRuntimeResources(context)
const arch = normalizeBuilderArch(context.arch)
validateAsarRuntimeDependencies(runtimeResources) validateAsarRuntimeDependencies(runtimeResources)
validateReaderSkillRuntime(runtimeResources) validateReaderSkillRuntime(runtimeResources)
validateSilkWasmRuntime(runtimeResources) validateSilkWasmRuntime(runtimeResources)
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName) const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
validateSherpaRuntime( validateRuntimeBinaryArchitecture(
runtimeResources, ffmpegPath,
context.electronPlatformName, 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') { if (context.electronPlatformName === 'darwin') {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
@@ -150,3 +258,7 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
exports.validateFfmpegRuntime = validateFfmpegRuntime exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime exports.validateSherpaRuntime = validateSherpaRuntime
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
+83
View File
@@ -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 }
+18
View File
@@ -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()
+103
View File
@@ -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 }
+1 -1
View File
@@ -98,7 +98,7 @@ const activityLine = Array.from(document.querySelectorAll('.analytics > .card'))
const values = { const values = {
REPORT_TITLE: title, REPORT_TITLE: title,
REPORT_DATE: reportDate, REPORT_DATE: reportDate,
DATE_RANGE: '今天', DATE_RANGE: '今日',
TIME_SPAN: statValues[2] || dateTimeRange, TIME_SPAN: statValues[2] || dateTimeRange,
HERO_SUMMARY: overview, HERO_SUMMARY: overview,
HERO_TAKEAWAY: '', HERO_TAKEAWAY: '',
+32 -3
View File
@@ -1,6 +1,7 @@
const fs = require('node:fs') const fs = require('node:fs')
const { execFileSync } = require('node:child_process') const { execFileSync } = require('node:child_process')
const path = require('node:path') const path = require('node:path')
const { readBinaryArchitectures } = require('./binary-arch.cjs')
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll'] 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 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) { function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
let packageRoot = '' let packageRoot = ''
try { try {
@@ -31,10 +57,11 @@ function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = pr
} catch { } catch {
return return
} }
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' const executable = ffmpegExecutableName(targetPlatform)
const ffmpegPath = path.join(packageRoot, executable) 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') const installScript = path.join(packageRoot, 'install.js')
console.log( console.log(
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}` `[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 }
+259 -23
View File
@@ -86,41 +86,115 @@ for (const required of [executable, script, config]) {
function patchPerSendPayload(scriptPath) { function patchPerSendPayload(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8') let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
const declarations = 'var triggerX1Payload;\nvar triggerX0;' const activeSend = ` const payloadData = hexToByteArray(payloadHex);
const patchedDeclarations =
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length); activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData); activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr); activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0)); activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));` 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 布局')
}
if (!source.includes(declarations) || !source.includes(originalSend)) { function patchSendContextCapture(scriptPath, strict = true) {
throw new Error('无法定位 wechat_chatter 连续发送补丁位置') 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(declarations, patchedDeclarations).replace(originalSend, patchedSend)
source = source.replace( source = source.replace(
' MMStartTask(triggerX0, triggerX1Payload);', staticAllocation,
' MMStartTask(triggerX0, activeTriggerX1Payload);' 'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
) )
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
}
source = source.replace( source = source.replace(
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', audioLengthMarker,
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);' `${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
source = source.replace(
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
) )
fs.writeFileSync(scriptPath, source) fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁') console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
} }
function patchImageHookReadiness(scriptPath) { function patchImageHookReadiness(scriptPath) {
@@ -146,6 +220,163 @@ function patchImageHookReadiness(scriptPath) {
console.log('[wechat-personal] 已应用图片 Hook 状态补丁') 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) { function patchWechatCoreModuleBase(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8') let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return if (source.includes('WeChat core module base:')) return
@@ -182,7 +413,8 @@ function addModifiedWorkNotice(scriptPath) {
* Upstream: https://github.com/yincongcyincong/wechat_chatter * Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18 * Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0) * License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging. * 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. * These modifications are not provided by the upstream author.
*/ */
@@ -194,7 +426,11 @@ function addModifiedWorkNotice(scriptPath) {
patchWechatCoreModuleBase(script) patchWechatCoreModuleBase(script)
patchPerSendPayload(script) patchPerSendPayload(script)
patchSendContextCapture(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script) patchImageHookReadiness(script)
patchCdnColdStartConfig(config)
patchCdnColdStart(script)
addModifiedWorkNotice(script) addModifiedWorkNotice(script)
fs.chmodSync(executable, 0o755) fs.chmodSync(executable, 0o755)
console.log('[wechat-personal] 运行时准备完成') console.log('[wechat-personal] 运行时准备完成')
+37
View File
@@ -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 }
+54
View File
@@ -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)
+11 -5
View File
@@ -7,8 +7,16 @@ import (
"testing" "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) { func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Setenv("HOME", t.TempDir()) setTestHome(t)
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"} old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"} latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil { if err := SaveCredentials(old); err != nil {
@@ -37,8 +45,7 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
} }
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) { func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
home := t.TempDir() home := setTestHome(t)
t.Setenv("HOME", home)
dir, err := AccountsDir() dir, err := AccountsDir()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -50,8 +57,7 @@ func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
} }
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) { func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
home := t.TempDir() setTestHome(t)
t.Setenv("HOME", home)
legacyDir, err := LegacyAccountsDir() legacyDir, err := LegacyAccountsDir()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_DEEPSEEK_API_KEY: string readonly VITE_DEEPSEEK_API_KEY: string
readonly VITE_SCHEDULED_REPORT_DEBUG: string
} }
interface ImportMeta { interface ImportMeta {
+139 -15
View File
@@ -329,6 +329,32 @@ body {
color: var(--accent); color: var(--accent);
} }
.timeline-month small { color: inherit; } .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; } .scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
.lazy-hint { .lazy-hint {
width: min(100%, 820px); width: min(100%, 820px);
@@ -787,6 +813,23 @@ body {
} }
.timeline-months { display: flex; gap: 6px; } .timeline-months { display: flex; gap: 6px; }
.timeline-months[hidden] { display: none; } .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 { .timeline-month {
flex: 0 0 auto; flex: 0 0 auto;
width: auto; width: auto;
@@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => `
let scrollLoadSuppressed = false let scrollLoadSuppressed = false
let activeMonthUpdatePending = false let activeMonthUpdatePending = false
let expandedTimelineYear = '' let expandedTimelineYear = ''
let expandedTimelineMonth = ''
let selectedTimelineMonth = ''
let selectedTimelineDate = ''
const tabPositions = new Map() const tabPositions = new Map()
let lastScrollTop = 0 let lastScrollTop = 0
let zoom = 1 let zoom = 1
@@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => `
const date = new Date(timestamp * 1000) const date = new Date(timestamp * 1000)
return date.getFullYear() + '-' + pad(date.getMonth() + 1) 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 kindOf = (message) => {
const data = message.contentData || {} const data = message.contentData || {}
const shareType = String(data.typeVal || '') const shareType = String(data.typeVal || '')
@@ -1317,6 +1370,15 @@ const renderExportScript = (name: string): string => `
if (months) months.hidden = !expanded 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 = () => { const renderTimeline = () => {
if (filtered.length === 0) { if (filtered.length === 0) {
expandedTimelineYear = '' expandedTimelineYear = ''
@@ -1327,18 +1389,36 @@ const renderExportScript = (name: string): string => `
for (const message of filtered) { for (const message of filtered) {
const key = monthKey(message) const key = monthKey(message)
if (key === 'unknown') continue 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() const yearGroups = new Map()
for (const [key, total] of groups) { for (const [key, group] of groups) {
const parts = key.split('-') const parts = key.split('-')
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], []) 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)) { if (!years.includes(expandedTimelineYear)) {
expandedTimelineYear = years[years.length - 1] || '' 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 = '' let html = ''
for (const year of years) { for (const year of years) {
const expanded = year === expandedTimelineYear const expanded = year === expandedTimelineYear
@@ -1348,10 +1428,18 @@ const renderExportScript = (name: string): string => `
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' + '" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
esc(year) + ' 年</button>' + esc(year) + ' 年</button>' +
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' + '<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
yearGroups.get(year).map((entry) => yearGroups.get(year).map((entry) => {
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' + const monthExpanded = entry.key === expandedTimelineMonth
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>' return '<div class="timeline-month-entry">' +
).join('') + '</div></section>' '<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>' timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
} }
@@ -1377,16 +1465,27 @@ const renderExportScript = (name: string): string => `
const activeMessage = atBottom const activeMessage = atBottom
? visible[visible.length - 1] || messages[messages.length - 1] ? visible[visible.length - 1] || messages[messages.length - 1]
: anchoredMessage || visible[0] || messages[0] : 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 let activeButton
timeline.querySelectorAll('.timeline-month').forEach((button) => { timeline.querySelectorAll('.timeline-month').forEach((button) => {
const active = button.dataset.month === key const active = button.dataset.month === key
button.classList.toggle('active', active) button.classList.toggle('active', active)
if (active) activeButton = button if (active) activeButton = button
}) })
timeline.querySelectorAll('.timeline-day').forEach((button) => {
button.classList.toggle('active', button.dataset.date === activeDate)
})
if (!activeButton) return if (!activeButton) return
const activeYear = key.split('-')[0] const activeYear = key.split('-')[0]
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear) if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key)
const timelineBounds = timeline.getBoundingClientRect() const timelineBounds = timeline.getBoundingClientRect()
const buttonBounds = activeButton.getBoundingClientRect() const buttonBounds = activeButton.getBoundingClientRect()
if (buttonBounds.top < timelineBounds.top) { if (buttonBounds.top < timelineBounds.top) {
@@ -1535,6 +1634,8 @@ const renderExportScript = (name: string): string => `
) )
} }
const applyFilters = (restorePosition = false) => { const applyFilters = (restorePosition = false) => {
selectedTimelineMonth = ''
selectedTimelineDate = ''
filtered = matchingMessages() filtered = matchingMessages()
renderTimeline() renderTimeline()
if (!restorePosition || !restoreTabPosition()) resetWindow(true) if (!restorePosition || !restoreTabPosition()) resetWindow(true)
@@ -1572,18 +1673,32 @@ const renderExportScript = (name: string): string => `
target.classList.add('located') target.classList.add('located')
window.setTimeout(() => target.classList.remove('located'), 1600) window.setTimeout(() => target.classList.remove('located'), 1600)
} }
const jumpToMonth = (key) => { const selectTimelineMonth = (key) => {
const index = filtered.findIndex((message) => monthKey(message) === 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 if (index < 0) return
selectedTimelineMonth = ''
selectedTimelineDate = key
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4)) windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
windowStart = Math.max(0, windowEnd - PAGE_SIZE) windowStart = Math.max(0, windowEnd - PAGE_SIZE)
renderWindow() renderWindow()
const target = list.querySelector('.message[data-index="' + index + '"]') const target = list.querySelector('.message[data-index="' + index + '"]')
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0) setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
setExpandedTimelineYear(key.split('-')[0]) const month = key.slice(0, 7)
timeline.querySelectorAll('.timeline-month').forEach((button) => { setExpandedTimelineYear(month.slice(0, 4))
button.classList.toggle('active', button.dataset.month === key) setExpandedTimelineMonth(month)
timeline.querySelectorAll('.timeline-day').forEach((button) => {
button.classList.toggle('active', button.dataset.date === key)
}) })
} }
const slideWindow = (direction) => { const slideWindow = (direction) => {
@@ -1620,6 +1735,10 @@ const renderExportScript = (name: string): string => `
const nearTop = currentTop < 180 const nearTop = currentTop < 180
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240 const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
lastScrollTop = currentTop lastScrollTop = currentTop
if (!scrollLoadSuppressed) {
selectedTimelineMonth = ''
selectedTimelineDate = ''
}
scheduleActiveMonthUpdate() scheduleActiveMonthUpdate()
if (scrollLoadSuppressed) return if (scrollLoadSuppressed) return
if (movingUp && nearTop) scheduleWindowSlide(-1) if (movingUp && nearTop) scheduleWindowSlide(-1)
@@ -1676,7 +1795,12 @@ const renderExportScript = (name: string): string => `
return return
} }
const button = event.target.closest('[data-month]') 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) const updateZoom = () => preview.style.setProperty('--zoom', zoom)
+148 -83
View File
@@ -25,11 +25,12 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case' import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import { imageFileQuality } from '../shared/image-quality' import { imageFileQuality } from '../shared/image-quality'
import { resolveMemberName } from '../shared/member-names' import { resolveMemberName } from '../shared/member-names'
import { filesystemSafeName } from '../shared/contact-name'
const jobs = new Set<string>() const jobs = new Set<string>()
const activeArchives = new Map<string, Archiver>() const activeArchives = new Map<string, Archiver>()
const safeFilePart = (value: string): string => const safeFilePart = (value: string, fallback = '聊天档案'): string =>
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案' filesystemSafeName(value, fallback)
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => { const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
try { try {
await fs.chmod(destination, 0o644) await fs.chmod(destination, 0o644)
@@ -643,6 +644,7 @@ const kindOf = (message: Message): ExportMessageKind => {
return 'text' return 'text'
} }
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"` const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
const csvBom = '\uFEFF'
function render(format: ExportRequest['format'], messages: Message[], name: string): string { function render(format: ExportRequest['format'], messages: Message[], name: string): string {
if (format === 'html') return renderExportPage(name) if (format === 'html') return renderExportPage(name)
@@ -650,7 +652,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2) return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
if (format === 'markdown') 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 `# ${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) => ...messages.map((m) =>
[ [
@@ -664,7 +666,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
.map(csv) .map(csv)
.join(',') .join(',')
) )
].join('\n') ].join('\r\n')
} }
interface SingleExportOptions { interface SingleExportOptions {
@@ -681,6 +683,8 @@ interface AllExportManifestEntry {
type: ExportTarget['type'] type: ExportTarget['type']
folder: string folder: string
messageCount: number messageCount: number
status: 'completed' | 'failed'
error?: string
} }
const writeAllExportManifest = async ( const writeAllExportManifest = async (
@@ -941,10 +945,7 @@ async function runSingleExport(
request.format === 'html' request.format === 'html'
? options.outputFolderName || safeFilePart(request.outputName) ? options.outputFolderName || safeFilePart(request.outputName)
: `${safeFilePart(request.outputName)}_${exportStamp()}` : `${safeFilePart(request.outputName)}_${exportStamp()}`
const root = const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
options.outputRoot ||
request.outputDirectory ||
(await resolveDefaultExportRoot(outputFolder))
await fs.mkdir(root, { recursive: true }) await fs.mkdir(root, { recursive: true })
const outputDir = join(root, outputFolder) const outputDir = join(root, outputFolder)
const outputPath = const outputPath =
@@ -1089,7 +1090,10 @@ async function runSingleExport(
} }
const voiceService = const voiceService =
request.includeMedia && chat.getChatDb() request.includeMedia && chat.getChatDb()
? new VoiceService(chat.getChatDb()!.getWcdb4Client()) ? new VoiceService(
chat.getChatDb()!.getWcdb4Client(),
chat.getChatDb()!.getWcdb4Client().getAccountRoot()
)
: null : null
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice') const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media' const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
@@ -1236,7 +1240,16 @@ async function runSingleExport(
markResourceExists(voiceUrl) markResourceExists(voiceUrl)
} }
message.voiceDataUrl = 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) { } catch (error) {
keepMediaError( keepMediaError(
request, request,
@@ -1478,10 +1491,13 @@ async function runSingleExport(
} else if (message.contentData.type === 'sticker' && stickerService) { } else if (message.contentData.type === 'sticker' && stickerService) {
const stickerSource = message.contentData.url || message.contentData.thumbUrl const stickerSource = message.contentData.url || message.contentData.thumbUrl
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5) const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
const decoded = result.data ? decodeDataUrl(result.data) : null const decoded = result.data
const stickerExtension = decoded ? detectAssetExtension(decoded.buffer) : null ? decodeDataUrl(result.data)
if (decoded && stickerExtension) { : stickerSource
const name = `sticker_${bufferHashPart(decoded.buffer)}.${stickerExtension}` ? await readAvatarAsset(stickerSource)
: null
if (decoded) {
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
const mediaUrl = `media/${name}` const mediaUrl = `media/${name}`
if (!(await resourceExists(mediaUrl))) { if (!(await resourceExists(mediaUrl))) {
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
@@ -1626,6 +1642,8 @@ async function runAllExport(
let outputDir = '' let outputDir = ''
const manifest: AllExportManifestEntry[] = [] const manifest: AllExportManifestEntry[] = []
let totalMessages = 0 let totalMessages = 0
let failedCount = 0
const failureMessages: string[] = []
try { try {
const targets = [...(request.targets || [])].sort((left, right) => const targets = [...(request.targets || [])].sort((left, right) =>
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1 left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
@@ -1658,80 +1676,121 @@ async function runAllExport(
const categoryName = target.type === 'group' ? '群聊' : '联系人' const categoryName = target.type === 'group' ? '群聊' : '联系人'
const categoryDir = join(outputDir, categoryName) const categoryDir = join(outputDir, categoryName)
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name) const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
await fs.mkdir(categoryDir, { recursive: true }) const folder = `${categoryName}/${folderName}`
const conversationOutputRoot = let result: ExportResult
request.format === 'html' ? categoryDir : join(categoryDir, folderName) try {
const basePercent = Math.floor((targetIndex / targets.length) * 100) await fs.mkdir(categoryDir, { recursive: true })
send({ const conversationOutputRoot =
jobId: request.jobId, request.format === 'html' ? categoryDir : join(categoryDir, folderName)
phase: 'reading', const basePercent = Math.floor((targetIndex / targets.length) * 100)
processed: 0, send({
percent: basePercent, jobId: request.jobId,
currentTargetIndex: targetIndex + 1, phase: 'reading',
currentTargetCount: targets.length, processed: 0,
currentTargetName: target.name, percent: basePercent,
currentTargetType: target.type currentTargetIndex: targetIndex + 1,
}) currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
const result = await runSingleExport( result = await runSingleExport(
{ {
...request, ...request,
targets: [target], targets: [target],
outputName: target.name, outputName: target.name,
zip: false zip: false
}, },
win, win,
voiceRecognition, voiceRecognition,
{ {
outputRoot: conversationOutputRoot, outputRoot: conversationOutputRoot,
outputFolderName: request.format === 'html' ? folderName : undefined, outputFolderName: request.format === 'html' ? folderName : undefined,
manageJob: false, manageJob: false,
selfInfo, selfInfo,
sendProgress: (childProgress) => { sendProgress: (childProgress) => {
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0)) const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
const percent = Math.min( const percent = Math.min(
99, 99,
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100) Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
) )
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
const now = Date.now() const now = Date.now()
const progressKey = `${targetIndex}:${phase}:${percent}` const progressKey = `${targetIndex}:${phase}:${percent}`
const terminal = phase === 'failed' || phase === 'cancelled' const terminal = phase === 'failed' || phase === 'cancelled'
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
lastProgressKey = progressKey lastProgressKey = progressKey
lastProgressAt = now lastProgressAt = now
send({ send({
...childProgress, ...childProgress,
jobId: request.jobId, jobId: request.jobId,
phase, phase,
percent, percent,
outputPath: undefined, outputPath: undefined,
currentTargetIndex: targetIndex + 1, currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length, currentTargetCount: targets.length,
currentTargetName: target.name, currentTargetName: target.name,
currentTargetType: target.type currentTargetType: target.type
}) })
}
} }
} )
) } catch (error) {
if (!result.success) { const message = error instanceof Error ? error.message : String(error)
if (result.error === '已取消') throw new Error('已取消') if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
throw new Error(`${target.name}:${result.error || '导出失败'}`) result = { success: false, error: message }
} }
const messageCount = result.messageCount || 0 if (!result.success) {
totalMessages += messageCount if (result.error === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
manifest.push({ const error = `${target.name}:${result.error || '导出失败'}`
id: target.userMd5, failedCount += 1
name: target.name, failureMessages.push(error)
type: target.type, manifest.push({
folder: `${categoryName}/${folderName}`, id: target.userMd5,
messageCount 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, '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 let completedPath = outputDir
if (request.zip) { if (request.zip) {
@@ -1750,17 +1809,23 @@ async function runAllExport(
send({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'completed', phase: failedCount ? 'failed' : 'completed',
processed: totalMessages, processed: totalMessages,
total: totalMessages, total: totalMessages,
percent: 100, percent: 100,
error: finalError,
outputPath: completedPath, outputPath: completedPath,
currentTargetIndex: targets.length, currentTargetIndex: targets.length,
currentTargetCount: targets.length, currentTargetCount: targets.length,
currentTargetName: targets.at(-1)?.name, currentTargetName: targets.at(-1)?.name,
currentTargetType: targets.at(-1)?.type 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
const cancelled = !jobs.has(request.jobId) || message === '已取消' const cancelled = !jobs.has(request.jobId) || message === '已取消'
+243 -49
View File
@@ -1,7 +1,10 @@
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import crypto from 'node:crypto'
import { appendFileSync } from 'node:fs'
import fs from 'fs-extra' import fs from 'fs-extra'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'node:url'
import { import {
GroupReportExportRequest, GroupReportExportRequest,
GroupReportExportResult, GroupReportExportResult,
@@ -15,6 +18,9 @@ import {
import { resolveMd5, getGroupSnapshot } from './services/chat-service' import { resolveMd5, getGroupSnapshot } from './services/chat-service'
import { imageInsightService } from './services/image-insight-service' import { imageInsightService } from './services/image-insight-service'
import { getReportTemplate } from '../shared/report-templates' 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> = { const LEGACY_TEMPLATE_FILES: Record<string, string> = {
v1: 'mobile_daily_report_v1.html', v1: 'mobile_daily_report_v1.html',
@@ -33,6 +39,40 @@ const templatePath = (templateId?: string): string => {
return found 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 => const escapeHtml = (value: unknown): string =>
String(value ?? '') String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -53,11 +93,13 @@ const hashName = (name: string): number => {
return hash return hash
} }
const fallbackAvatar = (name: string): string => { type RenderedAvatar = { source: string; fallback: boolean }
const fallbackAvatar = (name: string): RenderedAvatar => {
const hue = hashName(name) % 360 const hue = hashName(name) % 360
const initial = escapeHtml(Array.from(name.trim())[0] || '?') 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>` 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 => { const imageMimeType = (contentType: string | null, source: string): string => {
@@ -69,9 +111,9 @@ const imageMimeType = (contentType: string | null, source: string): string => {
return 'image/jpeg' 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 (!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 { try {
if (/^https?:\/\//i.test(source)) { 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}`) if (!response.ok) throw new Error(`HTTP ${response.status}`)
const mime = imageMimeType(response.headers.get('content-type'), source) 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 localPath = source.startsWith('file://') ? new URL(source) : source
const buffer = await fs.readFile(localPath) 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) { } catch (error) {
console.warn(`[GroupReport] avatar fallback for ${name}:`, error) console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
return fallbackAvatar(name) return fallbackAvatar(name)
@@ -148,6 +190,58 @@ const heatClass = (heat: ReportHeat): string => {
const replacePlaceholder = (html: string, key: string, value: string): string => const replacePlaceholder = (html: string, key: string, value: string): string =>
html.replaceAll(`{{${key}}}`, value) 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 = ( const sectionMeta = (
request: GroupReportExportRequest, request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta> key: keyof NonNullable<typeof request.report.sectionMeta>
@@ -170,7 +264,8 @@ const overflowNote = (
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => { const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request 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) const avatarNames = new Set<string>(metadata.heroParticipants)
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name))) report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
report.importantMessages.forEach((message) => avatarNames.add(message.sender)) 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?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner)) report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map<string, string>() const avatars = new Map<string, RenderedAvatar>()
await Promise.all( await Promise.all(
Array.from(avatarNames).map(async (name) => { Array.from(avatarNames).map(async (name) => {
avatars.set(name, await embedAvatar(metadata.avatars[name], 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 heroNames = selectHeroParticipantNames(metadata.heroParticipants)
const heroAvatars = heroNames const heroAvatars = heroNames
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`) .map((name) => renderAvatar(name, 'hero', '', name))
.join('') .join('')
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section' const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
const topicCards = report.topics const topicCards = report.topics
.map( .map(
(topic) => `<div class="card topic-card"> (topic) => `<div class="card topic-card tm-fragment tm-topic-card">
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div> <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> <div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
<p>${escapeHtml(topic.summary)}</p> <p>${escapeHtml(topic.summary)}</p>
${ ${
@@ -237,15 +342,15 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
} }
} }
if (!imageUrl) return '' 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) .slice(0, 5)
.map( .map(
(name) => (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> .join('')}</div>
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</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 const importantMessages = report.importantMessages
.map( .map(
(message) => `<div class="important-card"> (message) => `<div class="important-card tm-fragment tm-message tm-message--important">
<img class="avatar" src="${avatar(message.sender)}" alt=""> ${renderAvatar(message.sender, 'message', 'avatar')}
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div> <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">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></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>` </div>`
) )
.join('') .join('')
@@ -273,12 +378,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const quoteBlocks = report.quotes const quoteBlocks = report.quotes
.map( .map(
(quote) => (quote) =>
`<div class="chat-block">${quote.messages `<div class="chat-block tm-fragment tm-quote">${quote.messages
.map( .map(
( (
message message
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><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">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div> <div class="chat-name tm-message__author">${escapeHtml(message.sender)}</div><div class="chat-bubble tm-message__text">${escapeHtml(message.content)}</div>
</div></div>` </div></div>`
) )
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</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 || []) const todoCards = (report.todos || [])
.map( .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> <b>${escapeHtml(item.task)}</b>
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div> <div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</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 || []) const unresolvedCards = (report.unresolved || [])
.map( .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> <b>${escapeHtml(item.question)}</b>
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div> <div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
<div class="action-note">${escapeHtml(item.note)}</div> <div class="action-note">${escapeHtml(item.note)}</div>
@@ -324,7 +429,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const reversalCards = (report.reversals || []) const reversalCards = (report.reversals || [])
.map( .map(
(item) => `<div class="qa-card"> (item) => `<div class="qa-card tm-fragment tm-reversal-card">
<b>${escapeHtml(item.topic)}</b> <b>${escapeHtml(item.topic)}</b>
<div>最初:${escapeHtml(item.initialView)}</div> <div>最初:${escapeHtml(item.initialView)}</div>
<div>后来:${escapeHtml(item.finalView)}</div> <div>后来:${escapeHtml(item.finalView)}</div>
@@ -349,7 +454,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
.filter((item) => item.imageUrl) // 只显示加载成功的图 .filter((item) => item.imageUrl) // 只显示加载成功的图
.map( .map(
(item) => `<div class="vision-card"> (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="vision-body">
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div> <div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
<div class="vision-description">${escapeHtml(item.description)}</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 || []) const voiceCards = (report.media?.voiceHighlights || [])
.map( .map(
(item) => `<div class="qa-card"> (item) => `<div class="qa-card tm-fragment tm-voice-card">
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b> <b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
<div>${escapeHtml(item.note)}</div> <div>${escapeHtml(item.note)}</div>
</div>` </div>`
@@ -372,8 +477,8 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const voiceRankCards = (report.analytics.voiceLeaderboard || []) const voiceRankCards = (report.analytics.voiceLeaderboard || [])
.map( .map(
(item, index) => `<div class="rank"> (item, index) => `<div class="rank tm-fragment tm-ranking-item">
<img src="${avatar(item.sender)}" alt=""> ${renderAvatar(item.sender, 'ranking')}
<b>${index + 1}. ${escapeHtml(item.sender)}</b> <b>${index + 1}. ${escapeHtml(item.sender)}</b>
<span>${item.count} 条 · ${item.durationSec} 秒</span> <span>${item.count} 条 · ${item.durationSec} 秒</span>
</div>` </div>`
@@ -394,7 +499,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
.slice(0, 5) .slice(0, 5)
.map( .map(
(speaker, index) => (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('') .join('')
@@ -424,7 +529,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const qaCards = report.qa const qaCards = report.qa
.map( .map(
(item) => (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('') .join('')
@@ -441,7 +546,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
unresolvedCount: report.unresolved.length 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> = { const values: Record<string, string> = {
TEMPLATE_CLASS: template.cssClass, TEMPLATE_CLASS: template.cssClass,
TEMPLATE_LABEL: escapeHtml(template.label), 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) for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键) // 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
html = html.replace(/\{\{[A-Z_]+\}\}/g, '') html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
return html return addReportCsp(injectReportTemplateFragmentContract(html), resolvedTemplate.source === 'builtin')
} }
const renderReportSnapshotHtml = async ( const renderReportSnapshotHtml = async (
request: GroupReportRenderSnapshotExportRequest request: GroupReportRenderSnapshotExportRequest
): Promise<string> => { ): Promise<string> => {
const template = getReportTemplate(request.templateId) const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
let html = await fs.readFile(templatePath(request.templateId), 'utf8') 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 = { const values = {
...request.snapshot.values, ...request.snapshot.values,
TEMPLATE_CLASS: template.cssClass, TEMPLATE_CLASS: template.cssClass,
@@ -565,7 +679,7 @@ const renderReportSnapshotHtml = async (
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate) REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
} }
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value) 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 ( export const extractGroupReportRenderSnapshot = async (
@@ -777,23 +891,68 @@ export const extractGroupReportRenderSnapshot = async (
const captureFullPage = async ( const captureFullPage = async (
htmlPath: string, htmlPath: string,
pngPath: string, pngPath: string,
templateId?: string templateId?: string,
templateOverride?: {
captureWidth: number
maxCaptureWidth: number
maxCaptureHeight?: number
}
): Promise<string> => { ): Promise<string> => {
const template = getReportTemplate(templateId) const template = templateOverride || getReportTemplate(templateId)
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
const maxCaptureHeight = templateOverride?.maxCaptureHeight || 20000
console.log(`[GroupReport] capture begin html=${htmlPath}`) console.log(`[GroupReport] capture begin html=${htmlPath}`)
const reportSessionPartition = `report-template-${crypto.randomUUID()}`
const reportWindow = new BrowserWindow({ const reportWindow = new BrowserWindow({
show: false, show: false,
opacity: 0,
skipTaskbar: true,
width: captureWidth, width: captureWidth,
height: 800, height: 800,
frame: false, frame: false,
backgroundColor: '#f3f5f7', 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 { try {
const readyToShow = new Promise<void>((resolve) => reportWindow.once('ready-to-show', resolve))
await reportWindow.loadFile(htmlPath) await reportWindow.loadFile(htmlPath)
await readyToShow
console.log('[GroupReport] capture loaded html') console.log('[GroupReport] capture loaded html')
await reportWindow.webContents.executeJavaScript(`Promise.all([ await reportWindow.webContents.executeJavaScript(`Promise.all([
document.fonts.ready, document.fonts.ready,
@@ -808,17 +967,29 @@ const captureFullPage = async (
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800)) height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
})`)) as { width: number; height: number } })`)) as { width: number; height: number }
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width))) const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height))) const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height)))
reportWindow.setContentSize(width, 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)) await new Promise((resolve) => setTimeout(resolve, 100))
console.log(`[GroupReport] capture native page width=${width} height=${height}`) 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() const png = image.toPNG()
if (png.length < 1000) throw new Error('生成的日报图片为空') if (png.length < 1000) throw new Error('生成的日报图片为空')
await fs.writeFile(pngPath, png) await fs.writeFile(pngPath, png)
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`) console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
return `data:image/png;base64,${png.toString('base64')}` return `data:image/png;base64,${png.toString('base64')}`
} finally { } finally {
reportWindow.webContents.session.webRequest.onBeforeRequest(null)
reportWindow.destroy() reportWindow.destroy()
} }
} }
@@ -830,10 +1001,14 @@ export const exportGroupReport = async (
// === enrich 在 render 之前:从群成员快照反推真头像 === // === enrich 在 render 之前:从群成员快照反推真头像 ===
await enrichAvatarsFromGroup(request.metadata) 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) await fs.ensureDir(outputDir)
const templateLabel = const resolvedTemplate = await resolveTemplate(request)
request.templateId === 'v1' const templateLabel = request.templateRef
? resolvedTemplate.definition.fileLabel
: request.templateId === 'v1'
? '经典版' ? '经典版'
: request.templateId === 'v2' : request.templateId === 'v2'
? '丰富版' ? '丰富版'
@@ -846,7 +1021,15 @@ export const exportGroupReport = async (
await fs.writeFile(htmlPath, html, 'utf8') await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date() const htmlEndedAt = new Date()
const pngStartedAt = 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() const pngEndedAt = new Date()
return { return {
success: true, success: true,
@@ -877,9 +1060,12 @@ export const exportGroupReportSnapshot = async (
request: GroupReportRenderSnapshotExportRequest request: GroupReportRenderSnapshotExportRequest
): Promise<GroupReportExportResult> => { ): Promise<GroupReportExportResult> => {
try { 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) 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 baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`) const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`) const pngPath = path.join(outputDir, `${baseName}.png`)
@@ -888,7 +1074,15 @@ export const exportGroupReportSnapshot = async (
await fs.writeFile(htmlPath, html, 'utf8') await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date() const htmlEndedAt = new Date()
const pngStartedAt = 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() const pngEndedAt = new Date()
return { return {
success: true, success: true,
+225 -1
View File
@@ -11,10 +11,22 @@ import {
import { exportGroupReport } from './group-report-service' import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report' import { GroupReportExportRequest } from '../shared/group-report'
import { generateAgentGroupReport } from './services/agent-group-report-service' 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 { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log' import { safeError, safeLog, safeWarn } from './safe-log'
import { apiTokenStore } from './api-token-store' import { apiTokenStore } from './api-token-store'
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service' 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_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131 export const DEFAULT_HTTP_PORT = 6131
@@ -35,6 +47,17 @@ interface RouteContext {
export interface HttpServerOptions { export interface HttpServerOptions {
tokenProvider?: () => string | null tokenProvider?: () => string | null
mediaProvider?: (messageId: string) => Promise<HttpImageResult> 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> type RouteHandler = (ctx: RouteContext) => void | Promise<void>
@@ -67,7 +90,7 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
if (!isAllowedCorsOrigin(origin)) return false if (!isAllowedCorsOrigin(origin)) return false
res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', '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') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
return true return true
} }
@@ -318,6 +341,11 @@ const routes: Record<string, RouteHandler> = {
if (!request?.report || !request?.metadata) { if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 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) const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result) sendJson(res, result.success ? 200 : 500, result)
}, },
@@ -367,7 +395,199 @@ 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 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( function createMediaRoute(
mediaProvider: (messageId: string) => Promise<HttpImageResult> mediaProvider: (messageId: string) => Promise<HttpImageResult>
@@ -424,6 +644,8 @@ export function startHttpServer(
): Promise<HttpServerHandle> { ): Promise<HttpServerHandle> {
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication()) const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
const mediaProvider = options.mediaProvider || readImageMedia const mediaProvider = options.mediaProvider || readImageMedia
const scheduledReportApi = createScheduledReportApi(options)
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => { const server: Server = http.createServer(async (req, res) => {
try { try {
@@ -437,6 +659,8 @@ export function startHttpServer(
} }
const handler = const handler =
routes[url.pathname] || routes[url.pathname] ||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX) (url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
? createMediaRoute(mediaProvider) ? createMediaRoute(mediaProvider)
: undefined) : undefined)
+423 -53
View File
@@ -16,7 +16,7 @@ import {
dialog, dialog,
protocol protocol
} from 'electron' } from 'electron'
import { basename, dirname, extname, join } from 'path' import { dirname, extname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs' import { existsSync, promises as fsPromises } from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
@@ -32,7 +32,6 @@ import {
type DecodedImage type DecodedImage
} from './image-decrypt-service' } from './image-decrypt-service'
import { import {
exportGroupReport,
exportGroupReportSnapshot, exportGroupReportSnapshot,
extractGroupReportRenderSnapshot extractGroupReportRenderSnapshot
} from './group-report-service' } from './group-report-service'
@@ -43,8 +42,9 @@ import {
saveGeneratedReport, saveGeneratedReport,
updateGeneratedReportTemplate updateGeneratedReportTemplate
} from './report-history-service' } from './report-history-service'
import { reportTemplateService } from './report-template-service'
import { registerReportTemplateIpc } from './report-template-ipc'
import type { import type {
GroupReportExportRequest,
GroupReportRenderSnapshotExportRequest GroupReportRenderSnapshotExportRequest
} from '../shared/group-report' } from '../shared/group-report'
import type { import type {
@@ -74,7 +74,7 @@ import type {
import { KeyServiceMac } from './key-service-mac' import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win' import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service' 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 { skillResourceService } from './services/skill-resource-service'
import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service' import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service'
import { isWechatRunning } from './services/wechat-process-status' import { isWechatRunning } from './services/wechat-process-status'
@@ -111,9 +111,24 @@ import {
} from './services/bootstrap-cache' } from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log' import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service' 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 { 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 { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
import type { PersonalWechatSendRequest } from '../shared/personal-wechat' 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 { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
import type { import type {
ListTextToSpeechVoicesRequest, ListTextToSpeechVoicesRequest,
@@ -142,13 +157,31 @@ import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-
import { VoiceBatchService } from './voice-pipeline/voice-batch-service' import { VoiceBatchService } from './voice-pipeline/voice-batch-service'
import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition' import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition'
import type { AiSearchPipelineRequest } from '../shared/ai-search' import type { AiSearchPipelineRequest } from '../shared/ai-search'
import type {
AskWechatConfig,
AskWechatQueryRequest,
AskWechatQueryResult
} from '../shared/query-agent'
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge' import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
// 消息身份的规范化在 main / renderer 之间必须一致,所以只从 shared 取一份实现。
import { normalizeMessageIdentity } from '../shared/local-query-api'
import { import {
isWindowsVcRuntimeMissingError, isWindowsVcRuntimeMissingError,
WINDOWS_VC_RUNTIME_ERROR_MESSAGE WINDOWS_VC_RUNTIME_ERROR_MESSAGE
} from '../shared/windows-runtime' } from '../shared/windows-runtime'
import { KnowledgeSearchService } from './knowledge/knowledge-search-service' 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 { 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 { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
import { runFirstLaunchMigration } from './app-data-migration' import { runFirstLaunchMigration } from './app-data-migration'
import { WechatShareConfigStore } from './wechat-share-config-store' import { WechatShareConfigStore } from './wechat-share-config-store'
@@ -167,7 +200,10 @@ let voiceService: VoiceService | null = null
let voiceRecognition: VoiceRecognitionUseCase | null = null let voiceRecognition: VoiceRecognitionUseCase | null = null
let voiceBatchService: VoiceBatchService | null = null let voiceBatchService: VoiceBatchService | null = null
let knowledgeSearchService: KnowledgeSearchService | null = null let knowledgeSearchService: KnowledgeSearchService | null = null
let localQueryApiService: LocalQueryApiService | null = null
let aiSearchPipelineService: AiSearchPipelineService | null = null let aiSearchPipelineService: AiSearchPipelineService | null = null
let queryAgentService: QueryAgentService | null = null
let askWechatService: AskWechatService | null = null
let imageDecryptService: ImageDecryptService | null = null let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null let stickerService: StickerService | null = null
let videoAssetService: VideoAssetService | null = null let videoAssetService: VideoAssetService | null = null
@@ -599,6 +635,23 @@ app.whenReady().then(async () => {
knowledgeSearchService?.indexVoiceTranscript(update) knowledgeSearchService?.indexVoiceTranscript(update)
) )
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService) 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) => { knowledgeSearchService.onStatusChange((status) => {
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status) if (!window.isDestroyed()) window.webContents.send('knowledge:status', status)
@@ -652,6 +705,8 @@ app.whenReady().then(async () => {
}) })
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately. // Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
await reportTemplateService.recover()
registerReportTemplateIpc()
createWindow() createWindow()
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => { wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
console.log('[WCDB4] async bootstrap complete') console.log('[WCDB4] async bootstrap complete')
@@ -739,6 +794,7 @@ app.whenReady().then(async () => {
chat.getCurrentAccountRoot() === selectedRoot chat.getCurrentAccountRoot() === selectedRoot
) { ) {
console.log('[WCDB4] db:init reuse current connection') console.log('[WCDB4] db:init reuse current connection')
void groupExitMonitorService.start(true)
return { success: true, monitoring: true } return { success: true, monitoring: true }
} }
const nextWechatDb = await WechatDb.create(key, selectedRoot) const nextWechatDb = await WechatDb.create(key, selectedRoot)
@@ -746,6 +802,7 @@ app.whenReady().then(async () => {
await nextWechatDb.closeAsync() await nextWechatDb.closeAsync()
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false } return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
} }
groupExitMonitorService.stop()
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot() const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot) { if (resolvedRoot) {
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录 // 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
@@ -761,17 +818,19 @@ app.whenReady().then(async () => {
const wcdb4Client = nextWechatDb.getWcdb4Client() const wcdb4Client = nextWechatDb.getWcdb4Client()
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false }) const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled) configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
voiceService = new VoiceService(wcdb4Client) voiceService = new VoiceService(wcdb4Client, resolvedRoot)
voiceRecognition?.connect(voiceService, resolvedRoot) voiceRecognition?.connect(voiceService, resolvedRoot)
stickerService = new StickerService(wcdb4Client) stickerService = new StickerService(wcdb4Client)
videoAssetService = new VideoAssetService(wcdb4Client) videoAssetService = new VideoAssetService(wcdb4Client)
const monitoring = await wcdb4Client.startMonitor((type, json) => { const monitoring = await wcdb4Client.startMonitor((type, json) => {
wcdb4Client.invalidateSessionCache() wcdb4Client.invalidateSessionCache()
groupExitMonitorService.notifyDatabaseChanged(json)
recallArchiveMonitor?.handleDatabaseChange(json) recallArchiveMonitor?.handleDatabaseChange(json)
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
} }
}) })
void groupExitMonitorService.start(monitoring)
const recentSession = sessions[0] const recentSession = sessions[0]
if (recentSession?.username) { if (recentSession?.username) {
void wcdb4Client void wcdb4Client
@@ -826,18 +885,22 @@ app.whenReady().then(async () => {
) )
const self = chat.getSelfAccountInfo() const self = chat.getSelfAccountInfo()
const settings = loadSettings() const settings = loadSettings()
const intelMac = process.platform === 'darwin' && process.arch === 'x64'
const intelEnvironment = intelMac ? await keyServiceMac.getIntelEnvironmentStatus() : undefined
const environment = { const environment = {
platform: process.platform, platform: process.platform,
architecture: process.arch,
osVersion: getOsVersionLabel(), osVersion: getOsVersionLabel(),
appVersion: `v${app.getVersion()}`, appVersion: `v${app.getVersion()}`,
wechatVersion: await detectWechatVersion(), wechatVersion: await detectWechatVersion(),
dataStructureVersion: detectDataStructureVersion(settings.dbRoot), dataStructureVersion: detectDataStructureVersion(settings.dbRoot),
dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid, dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid,
autoDetectSupported: process.platform === 'win32', autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
wechatRunning: await isWechatRunning(), wechatRunning: await isWechatRunning(),
accountIdentified: Boolean(self?.wxid), accountIdentified: Boolean(self?.wxid),
dbConnected: chat.isReady(), dbConnected: chat.isReady(),
encryptionAvailable: storage.encryptionAvailable encryptionAvailable: storage.encryptionAvailable,
...(intelEnvironment || {})
} }
return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) } return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) }
}) })
@@ -850,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) => { ipcMain.handle('key:pasteAndSaveDbKey', async (_, accountRoot: string) => {
const clipboardKey = clipboard.readText().trim() const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(String(accountRoot || ''), clipboardKey) return databaseKeyStore.save(String(accountRoot || ''), clipboardKey)
@@ -872,7 +942,7 @@ app.whenReady().then(async () => {
const result = const result =
process.platform === 'win32' process.platform === 'win32'
? await keyServiceWin.autoGetDbKey(60_000, onStatus) ? await keyServiceWin.autoGetDbKey(60_000, onStatus)
: await keyServiceMac.autoGetDbKey(onStatus) : await keyServiceMac.autoGetDbKey(onStatus, 60_000, accountRoot)
if (!result.success || !result.key) return result if (!result.success || !result.key) return result
const selectedRoot = String(accountRoot || '').trim() const selectedRoot = String(accountRoot || '').trim()
@@ -897,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 }) => { ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
const settings = loadSettings() const settings = loadSettings()
@@ -911,7 +995,7 @@ app.whenReady().then(async () => {
} }
const result = const result =
process.platform === 'win32' process.platform === 'win32'
? await keyServiceWin.autoGetImageKeyByMemoryScan(accountRoot, onStatus) ? await keyServiceWin.autoGetImageKey(accountRoot, onStatus, wxid)
: await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid) : await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid)
if (!result.success || !result.aesKey) return result if (!result.success || !result.aesKey) return result
@@ -1066,11 +1150,14 @@ app.whenReady().then(async () => {
return contacts return contacts
}) })
ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => { ipcMain.handle(
const avatars = await chat.getContactAvatars(usernames) 'db:getContactAvatars',
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars) async (_, usernames: string[], options?: { refresh?: boolean }) => {
return avatars const avatars = await chat.getContactAvatars(usernames, options)
}) if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
return avatars
}
)
ipcMain.handle( ipcMain.handle(
'db:getMessages', 'db:getMessages',
@@ -1108,6 +1195,83 @@ app.whenReady().then(async () => {
} }
) )
/**
* 「跳转到原聊天」的锚点读取。
*
* 与 `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) => { ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
const snapshot = await chat.getGroupSnapshotAsync(userMd5) const snapshot = await chat.getGroupSnapshotAsync(userMd5)
if (snapshot && chat.isReady()) { if (snapshot && chat.isReady()) {
@@ -1116,6 +1280,31 @@ app.whenReady().then(async () => {
return 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('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle( ipcMain.handle(
'knowledge:search', 'knowledge:search',
@@ -1134,6 +1323,18 @@ app.whenReady().then(async () => {
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化') if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
return knowledgeSearchService.startCurrentAccountIndex() 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) => { ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => {
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化') if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
return aiSearchPipelineService.run(request, (progress) => { return aiSearchPipelineService.run(request, (progress) => {
@@ -1151,6 +1352,27 @@ app.whenReady().then(async () => {
} }
}) })
ipcMain.handle('ai-search:getProviderStatus', () => aiProviderService.getAiSearchProviderStatus()) 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( ipcMain.handle(
'ai-search:authorizeExternalProvider', 'ai-search:authorizeExternalProvider',
(_, request: AiSearchExternalAuthorizationRequest) => { (_, request: AiSearchExternalAuthorizationRequest) => {
@@ -1223,9 +1445,6 @@ app.whenReady().then(async () => {
} }
}) })
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request)
})
ipcMain.handle( ipcMain.handle(
'report:exportSnapshot', 'report:exportSnapshot',
async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request) async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request)
@@ -1280,6 +1499,47 @@ app.whenReady().then(async () => {
return deleteGeneratedReport(reportId) 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) => { ipcMain.handle('report:reveal', async (_, filePath: string) => {
try { try {
shell.showItemInFolder(filePath) shell.showItemInFolder(filePath)
@@ -1307,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 () => { ipcMain.handle('voice:getModelStatus', async () => {
if (!voiceRecognition) throw new Error('Voice recognition is not initialized') if (!voiceRecognition) throw new Error('Voice recognition is not initialized')
return voiceRecognition.getModelStatus() return voiceRecognition.getModelStatus()
@@ -1659,12 +1964,21 @@ app.whenReady().then(async () => {
}) })
ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => { ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => {
groupExitMonitorService.stop()
const ok = chat.reopenWithRoot(accountRoot) const ok = chat.reopenWithRoot(accountRoot)
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' } if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
const client = chat.getChatDb()?.getWcdb4Client() const client = chat.getChatDb()?.getWcdb4Client()
if (client) { if (client) {
voiceService = new VoiceService(client) voiceService = new VoiceService(client, client.getAccountRoot())
voiceRecognition?.connect(voiceService, 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,避免自动获取扫描到旧目录 // 同步 imageKeyRoot,避免自动获取扫描到旧目录
const settings = loadSettings() const settings = loadSettings()
@@ -1698,6 +2012,7 @@ app.whenReady().then(async () => {
voiceBatchService?.cancel() voiceBatchService?.cancel()
voiceRecognition?.disconnect() voiceRecognition?.disconnect()
voiceService = null voiceService = null
groupExitMonitorService.stop()
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null) if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
return { success: true } return { success: true }
}) })
@@ -1777,13 +2092,64 @@ app.whenReady().then(async () => {
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect()) ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect()) ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus()) 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:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download()) ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({ ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
success: personalWechatRuntimeManager.cancelDownload() success: personalWechatRuntimeManager.cancelDownload()
})) }))
ipcMain.handle('wechat-personal:removeRuntime', async () => { ipcMain.handle('wechat-personal:removeRuntime', async () => {
await personalWechatSendService.terminate() await personalWechatSendService.terminate(true)
return personalWechatRuntimeManager.remove() return personalWechatRuntimeManager.remove()
}) })
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => { ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
@@ -1794,43 +2160,44 @@ app.whenReady().then(async () => {
return error ? { success: false, error } : { success: true } return error ? { success: false, error } : { success: true }
}) })
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind()) ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) => ipcMain.handle(
personalWechatSendService.send(request) 'wechat-personal:sendGeneratedTtsVoice',
) async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
ipcMain.handle('wechat-personal:selectImage', async (event) => { const to = String(request?.to || '').trim()
const window = BrowserWindow.fromWebContents(event.sender) const filePath = String(request?.filePath || '').trim()
const result = await dialog.showOpenDialog(window!, { let fromId = ''
title: '选择要通过个人微信发送的图片', try {
properties: ['openFile'], const self = await chat.getSelfAccountInfoAsync()
filters: [ fromId = String(self?.wxid || '').trim()
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }, } catch {
{ name: '所有文件', extensions: ['*'] } // Windows 语音发送需要当前账号 wxid;Gateway 会继续规范化失败结果。
] }
}) const action = await wechatActionGateway.execute({
if (result.canceled || !result.filePaths[0]) return { canceled: true } origin: 'user_tts',
return { purpose: 'tts_voice',
canceled: false, triggerType: 'user',
path: result.filePaths[0], recipient: {
name: basename(result.filePaths[0]) 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:selectVoice', async (event) => { ipcMain.handle('wechat-personal:getVoiceDiagnostic', () =>
const window = BrowserWindow.fromWebContents(event.sender) personalWechatSendService.getLatestVoiceDiagnostic()
const result = await dialog.showOpenDialog(window!, { )
title: '选择要通过个人微信发送的语音',
properties: ['openFile'],
filters: [
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
{ name: '所有文件', extensions: ['*'] }
]
})
if (result.canceled || !result.filePaths[0]) return { canceled: true }
return { canceled: false, path: result.filePaths[0], name: basename(result.filePaths[0]) }
})
ipcMain.handle('agent-hub:selectTestImage', async (event) => { ipcMain.handle('agent-hub:selectTestImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender) const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, { const result = await dialog.showOpenDialog(window!, {
title: '选择要测试发送的图片', title: '选择要发送的图片',
properties: ['openFile'], properties: ['openFile'],
filters: [ filters: [
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }, { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
@@ -1850,6 +2217,7 @@ app.whenReady().then(async () => {
} }
await agentHubService.start(settings) await agentHubService.start(settings)
await scheduledReportService.start()
setupTray() setupTray()
if (TRAY_MODE) app.dock?.hide() if (TRAY_MODE) app.dock?.hide()
@@ -1886,6 +2254,8 @@ app.on('before-quit', (event) => {
void (async () => { void (async () => {
agentHubService.stop() agentHubService.stop()
scheduledReportService.stop()
groupExitMonitorService.stop()
flushBootstrapCacheWritesSync() flushBootstrapCacheWritesSync()
const [, nativeCallsDrained] = await Promise.all([ const [, nativeCallsDrained] = await Promise.all([
apiServer.stop().catch(() => undefined), apiServer.stop().catch(() => undefined),
+335 -65
View File
@@ -1,11 +1,12 @@
import { app } from 'electron' import { app } from 'electron'
import { execFile } from 'child_process' import { execFile, spawn } from 'child_process'
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import { promisify } from 'util' import { promisify } from 'util'
import { isValidDatabaseKey } from './database-key-store' import { isValidDatabaseKey } from './database-key-store'
import crypto from 'crypto' import crypto from 'crypto'
import { findResource, getResourceCandidates } from './resource-paths' import { findResource, getResourceCandidates } from './resource-paths'
import { detectWechatVersion } from './services/connection-diagnostics'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -24,12 +25,279 @@ export interface ImageKeyResult {
error?: string 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 { export class KeyServiceMac {
private getHelperPath(): string { private getMacKeyRuntimeDir(): string {
const helperPath = findResource('xkey_helper') 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) { if (!helperPath) {
throw new Error( throw new Error(
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})` `找不到 ${resourceName}(已检查:${getResourceCandidates(resourceName).join(';')})`
) )
} }
return helperPath return helperPath
@@ -61,77 +329,47 @@ export class KeyServiceMac {
// Try the next process lookup strategy. // Try the next process lookup strategy.
} }
} }
throw new Error('未找到微信主进程,请先启动并登录微信') 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 }
} }
async autoGetDbKey( async autoGetDbKey(
onStatus?: (message: string) => void, onStatus?: (message: string) => void,
timeoutMs = 60_000 timeoutMs = 60_000,
accountRoot?: string
): Promise<DatabaseKeyResult> { ): Promise<DatabaseKeyResult> {
if (onStatus) {
const emitStatus = onStatus
onStatus = (message: string): void =>
emitStatus(message.replace(/Frida/gi, '连接组件'))
}
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
return { success: false, error: '自动获取密钥目前仅支持 macOS' } return { success: false, error: '自动获取密钥目前仅支持 macOS' }
} }
if (await this.isSipEnabled()) { if (await this.isSipEnabled()) {
return { return {
success: false, success: false,
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。' error: '当前系统还未完成连接环境准备,请按页面提示完成设置。'
} }
} }
try { 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?.('正在查找微信进程...') onStatus?.('正在查找微信进程...')
const pid = await this.getWeChatPid() const pid = await this.getWeChatPid()
const helperPath = this.getHelperPath() const invocation = buildAppleSiliconXkeyInvocation(wechatVersion, pid, timeoutMs)
const waitMs = Math.max(30_000, timeoutMs) const isWechat413 = invocation.mode === 'wechat-4.1.13'
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30 const helperPath = this.getHelperPath(invocation.resourceName)
onStatus?.('正在请求管理员授权...') onStatus?.('正在请求管理员授权...')
const scriptLines = [ const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`, `set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, `set cmd to quoted form of helperPath & " ${invocation.args.join(' ')}"`,
`set timeoutSec to ${timeoutSeconds}`, `set timeoutSec to ${invocation.timeoutSeconds}`,
'try', 'try',
'with timeout of timeoutSec seconds', 'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges', 'set outText to do shell script cmd with administrator privileges',
@@ -141,25 +379,57 @@ export class KeyServiceMac {
'return "ERR::" & errNum & "::" & errMsg', 'return "ERR::" & errNum & "::" & errMsg',
'end try' 'end try'
] ]
onStatus?.('授权后 需要在微信登录界面 点击登录微信') onStatus?.(
isWechat413
? '授权后请在微信登录界面点击“登录”,已有登录凭据时通常不需要扫码'
: '授权后 需要在微信登录界面 点击登录微信'
)
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
'/usr/bin/osascript', '/usr/bin/osascript',
scriptLines.flatMap((line) => ['-e', line]), scriptLines.flatMap((line) => ['-e', line]),
{ timeout: waitMs + 20_000 } { timeout: invocation.execTimeoutMs }
) )
const output = String(stdout).trim() const output = String(stdout).trim()
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' } if (output.startsWith('ERR::-128')) {
if (output.startsWith('ERR::')) { return { success: false, error: '已取消管理员授权' }
return {
success: false,
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
}
} }
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 ? '密钥获取成功' : '密钥获取失败') onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result return result
} catch (error) { } 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: '密钥工具执行失败,请确认微信仍在运行后重试。'
}
} }
} }
+90 -5
View File
@@ -1,10 +1,15 @@
import { join, dirname, delimiter } from 'path' 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 { execFile } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import os from 'os' import os from 'os'
import crypto from 'crypto' import crypto from 'crypto'
import { getResourceRoots as getSharedResourceRoots } from './resource-paths' import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
import {
deriveV4ImageKeys,
extractKvcommCode,
normalizeV4AccountId
} from '../shared/wechat-image-key-derivation'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -683,7 +688,8 @@ export class KeyService {
if (loginRequired) { if (loginRequired) {
return { return {
success: false, success: false,
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ', error:
'微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
logs logs
} }
} }
@@ -696,8 +702,7 @@ export class KeyService {
onProgress?: (message: string) => void, onProgress?: (message: string) => void,
wxidParam?: string wxidParam?: string
): Promise<ImageKeyResult> { ): Promise<ImageKeyResult> {
void wxidParam return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress, wxidParam)
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
} }
// --- 内存扫描备选方案(融合 Dart+Python 优点)--- // --- 内存扫描备选方案(融合 Dart+Python 优点)---
@@ -706,7 +711,8 @@ export class KeyService {
async autoGetImageKeyByMemoryScan( async autoGetImageKeyByMemoryScan(
userDir: string, userDir: string,
onProgress?: (message: string) => void onProgress?: (message: string) => void,
wxidParam?: string
): Promise<ImageKeyResult> { ): Promise<ImageKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
@@ -781,6 +787,18 @@ export class KeyService {
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) 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 // 2. 找微信 PID
const pid = await this.findWeChatPid() const pid = await this.findWeChatPid()
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } 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( private async _findTemplateData(
userDir: string, userDir: string,
limit: number = 32 limit: number = 32
+536 -72
View File
@@ -1,8 +1,11 @@
import { monitorEventLoopDelay } from 'perf_hooks'
import * as chat from '../services/chat-service' import * as chat from '../services/chat-service'
import type { import type {
KnowledgeAttachmentMetadata, KnowledgeAttachmentMetadata,
KnowledgeEvidence, KnowledgeEvidence,
KnowledgeMessageKind, KnowledgeMessageKind,
KnowledgePassProgress,
KnowledgeRuntimeState,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeSearchIpcRequest, KnowledgeSearchIpcRequest,
@@ -32,6 +35,42 @@ const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
const MAX_SENDER_ENRICHMENT_SESSIONS = 32 const MAX_SENDER_ENRICHMENT_SESSIONS = 32
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000 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 = { type PendingVoiceTranscriptIndex = {
update: VoiceTranscriptUpdate update: VoiceTranscriptUpdate
waiters: Array<{ waiters: Array<{
@@ -43,7 +82,13 @@ type PendingVoiceTranscriptIndex = {
type SenderEnrichmentSession = { type SenderEnrichmentSession = {
lastUsedAt: number lastUsedAt: number
contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>> 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 { function looksLikeOpaqueSenderId(value: string | undefined): boolean {
@@ -81,6 +126,10 @@ function sourceMessageId(message: chat.FormattedMessage): string {
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind { function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice' 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') { if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6' return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file' ? 'file'
@@ -199,7 +248,12 @@ export class KnowledgeSearchService {
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>() private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>() private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>() 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 wcdbQueueMsTotal = 0
private wcdbExecutionMsTotal = 0 private wcdbExecutionMsTotal = 0
private voiceTranscriptResolver: private voiceTranscriptResolver:
@@ -208,6 +262,17 @@ export class KnowledgeSearchService {
private voiceIndexTail: Promise<void> = Promise.resolve() private voiceIndexTail: Promise<void> = Promise.resolve()
private voiceIndexFlushScheduled = false private voiceIndexFlushScheduled = false
private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>() 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) { constructor(userDataPath: string, workerPath: string) {
this.service = new KnowledgeService(userDataPath, workerPath) this.service = new KnowledgeService(userDataPath, workerPath)
@@ -218,13 +283,32 @@ export class KnowledgeSearchService {
if (!accountId) return this.emptyStatus('') if (!accountId) return this.emptyStatus('')
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
if (this.indexing.has(accountId)) return current 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 = { const started: KnowledgeRuntimeStatus = {
...current, ...current,
state: current.indexedMessageCount ? 'syncing' : 'building', state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0, processedMessages: 0,
totalMessages: current.sourceMessageCount, totalMessages: current.sourceMessageCount,
estimatedRemainingMs: null, estimatedRemainingMs: null,
lastError: undefined lastError: undefined,
pass: { ...this.passProgress }
} }
this.publishStatus(started) this.publishStatus(started)
const task = this.indexAccount(accountId) const task = this.indexAccount(accountId)
@@ -239,6 +323,16 @@ export class KnowledgeSearchService {
}) })
.finally(() => { .finally(() => {
this.indexing.delete(accountId) 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) void this.refreshStatus(accountId).catch(() => undefined)
}) })
this.indexing.set(accountId, task) this.indexing.set(accountId, task)
@@ -248,6 +342,26 @@ export class KnowledgeSearchService {
return started 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 * The voice cache remains owned by the voice pipeline. Knowledge only reads
* a current-account snapshot while constructing a derived local index. * a current-account snapshot while constructing a derived local index.
@@ -316,8 +430,10 @@ export class KnowledgeSearchService {
} }
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> { async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
// 源数据最新活跃时间与索引状态无关,先取一次(零额外 WCDB 调用:读的是已缓存的 Session 列表)。
const sourceLatestAt = this.sourceLatestAt()
const accountId = this.currentAccountId() const accountId = this.currentAccountId()
if (!accountId) return this.searchFallback(request, 'unavailable') if (!accountId) return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
try { try {
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = { const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
accountId, accountId,
@@ -329,24 +445,87 @@ export class KnowledgeSearchService {
senderIds: request.senderIds, senderIds: request.senderIds,
startTime: request.startTime === undefined ? undefined : request.startTime * 1000, startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
endTime: request.endTime === undefined ? undefined : request.endTime * 1000 endTime: request.endTime === undefined ? undefined : request.endTime * 1000
,conversationBoundary: request.conversationBoundary
} }
const result = await this.searchKnowledge(searchRequest) const result = await this.searchKnowledge(searchRequest)
// An existing derived database can answer while its next incremental pass is running. // 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. // Never turn an interactive global search into another full WCDB scan during that pass.
if (result.state === 'ready' || result.evidence.length) { 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)) { if (this.indexing.has(accountId)) {
return { return {
...result, ...result,
source: 'knowledge', source: 'knowledge',
totalMessages: result.indexedMessageCount totalMessages: result.indexedMessageCount,
sourceLatestAt
} }
} }
return this.searchFallback(request, 'unavailable') return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
} catch (error) { } catch (error) {
console.warn('[Knowledge] search failed, using legacy fallback:', 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)
} }
} }
@@ -382,66 +561,244 @@ export class KnowledgeSearchService {
} }
private async indexAccount(accountId: string): Promise<void> { private async indexAccount(accountId: string): Promise<void> {
const contacts = await this.listContacts() // 整遍后台索引走 background 通道:交互查询可以插到它前面,不至于被 pass 拖慢。
let processedMessages = 0 const contacts = await this.listContacts('background')
const startedAt = Date.now() 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.publishStatus({
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)), ...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building', state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0, processedMessages: 0,
totalMessages: null, 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 // WCDB rejects overlapping async pagination. Queue every archive read so
// background indexing and an interactive fallback search can interleave safely. // 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 const sourceMessages = messages
.map((message) => this.toSourceMessage(accountId, contact.md5, message)) .map((message) => this.toSourceMessage(accountId, contact.md5, message))
.filter((message): message is KnowledgeSourceMessage => Boolean(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, accountId,
conversations: [ conversations: [
{ {
conversationId: contact.md5, conversationId: contact.md5,
completeSnapshot: true, // delta 模式下绝不能声明"完整快照":否则 store 会把"不在 delta 里的历史消息"
messages: sourceMessages // 误判为被删除,从而整段重建这个会话,增量就白做了。
completeSnapshot: !isDelta,
messages: sourceMessages,
...(sourceHighWaterTime && sourceHighWaterTime > 0 ? { sourceHighWaterTime } : {})
} }
], ],
chunker: DEFAULT_KNOWLEDGE_CHUNKER, chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
// 只有「这一遍真的读完了全部会话」(没有任何跳过、没有取消)才写入总量口径,
// 否则会把增量 pass 的部分计数冒充成全量。
//
// 这里数的是真正被建模进索引的源消息,不是"扫到的原始条数";后者(含不可建模的
// 图片/空正文)另走 pass 进度里的 scannedMessages。两个数字回答不同问题。
sourceMessageCount: 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) => { (progress) => {
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({ this.publishStatus({
...current, ...current,
state: current.indexedMessageCount ? 'syncing' : 'building', state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: processedMessages + progress.processedMessages, processedMessages: indexedMessages + progress.processedMessages,
totalMessages: null, totalMessages: null,
currentConversationId: progress.conversationId, 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) const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({ this.publishStatus({
...current, ...current,
state: current.indexedMessageCount ? 'syncing' : 'building', state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages, processedMessages: indexedMessages,
totalMessages: null, totalMessages: null,
currentConversationId: contact.md5, 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, { await this.refreshStatus(accountId, {
processedMessages, processedMessages: indexedMessages,
totalMessages: processedMessages, totalMessages: null,
startedAt 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( private async searchFallback(
@@ -481,12 +838,14 @@ export class KnowledgeSearchService {
.filter(({ message, score }) => { .filter(({ message, score }) => {
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from) const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
const termMatches = !terms.length || score > 0 const termMatches = !terms.length || score > 0
return senderMatches && termMatches const boundaryMatches = !request.conversationBoundary || message.contentData?.type !== 'system'
return senderMatches && termMatches && boundaryMatches
}) })
.sort( .sort(
(left, right) => (left, right) =>
right.score - left.score || 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))) .slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
const result: KnowledgeSearchIpcResult = { const result: KnowledgeSearchIpcResult = {
@@ -495,6 +854,9 @@ export class KnowledgeSearchService {
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable', state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
// fallback 是直接扫源数据,不走派生索引,因此没有索引覆盖口径可言。
indexLatestAt: null,
sourceLatestAt: this.sourceLatestAt(),
totalMessages, totalMessages,
timings: { timings: {
...emptyKnowledgeSearchTimings(), ...emptyKnowledgeSearchTimings(),
@@ -632,6 +994,9 @@ export class KnowledgeSearchService {
voiceCoverage.voiceCoverageComplete = voiceCoverage.voiceCoverageComplete =
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
} }
const indexLatestParts = partialResults
.map((result) => result.indexLatestAt)
.filter((value): value is number => typeof value === 'number' && value > 0)
return { return {
state: partialResults.some((result) => result.state === 'ready') state: partialResults.some((result) => result.state === 'ready')
? 'ready' ? 'ready'
@@ -640,6 +1005,8 @@ export class KnowledgeSearchService {
: 'unavailable', : 'unavailable',
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)), indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)), indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
// 多个分片取最新的那个:只要有一部分索引更新,整体覆盖口径就按它算。
indexLatestAt: indexLatestParts.length ? Math.max(...indexLatestParts) : null,
evidence: mergedEvidence, evidence: mergedEvidence,
timings, timings,
voiceCoverage voiceCoverage
@@ -673,16 +1040,20 @@ export class KnowledgeSearchService {
} }
} }
private listContacts(): ReturnType<typeof chat.listContactsAsync> { private listContacts(lane: WcdbReadLane = 'interactive'): ReturnType<typeof chat.listContactsAsync> {
return this.enqueueWcdbRead(() => chat.listContactsAsync()) return this.enqueueWcdbRead(() => chat.listContactsAsync(), lane)
} }
private listMessages( private listMessages(
conversationId: string, conversationId: string,
startTime?: number, startTime?: number,
endTime?: number endTime?: number,
lane: WcdbReadLane = 'interactive'
): ReturnType<typeof chat.listMessagesAsync> { ): 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 { private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage {
@@ -804,22 +1175,36 @@ export class KnowledgeSearchService {
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
}, },
source: 'knowledge', 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( private async enrichEvidenceSenders(
evidence: KnowledgeEvidence[], evidence: KnowledgeEvidence[],
retrievalSessionId?: string retrievalSessionId?: string
): Promise<KnowledgeEvidence[]> { ): Promise<KnowledgeEvidence[]> {
const candidateConversationIds = Array.from( // 先按会话聚合需要的 sender,避免"每条 evidence 一次调用"。
new Set( const wxidsByConversation = new Map<string, Set<string>>()
evidence for (const item of evidence) {
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender)) if (!item.senderId || !looksLikeOpaqueSenderId(item.sender)) continue
.map((item) => item.conversationId) let bucket = wxidsByConversation.get(item.conversationId)
) if (!bucket) {
).slice(0, MAX_SENDER_NAME_CONVERSATIONS) bucket = new Set<string>()
if (!candidateConversationIds.length) return evidence wxidsByConversation.set(item.conversationId, bucket)
}
bucket.add(item.senderId)
}
if (!wxidsByConversation.size) return evidence
const session = retrievalSessionId const session = retrievalSessionId
? this.senderEnrichmentSession(retrievalSessionId) ? this.senderEnrichmentSession(retrievalSessionId)
@@ -829,20 +1214,35 @@ export class KnowledgeSearchService {
const groupConversationIds = new Set( const groupConversationIds = new Set(
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5) 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>>() const memberNamesByConversation = new Map<string, Map<string, string>>()
for (const conversationId of candidateConversationIds) { for (const conversationId of candidateConversationIds) {
if (!groupConversationIds.has(conversationId)) continue const requested = Array.from(wxidsByConversation.get(conversationId) || [])
let snapshot = session?.groupSnapshots.get(conversationId) if (!requested.length) continue
if (!snapshot) { let memberNames = session?.groupMemberNames.get(conversationId)
snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId)) // 只查缓存里还没有的 wxid —— 同一 session 的后续 probe 因此不会重复读 WCDB。
session?.groupSnapshots.set(conversationId, snapshot) 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( if (memberNames?.size) memberNamesByConversation.set(conversationId, memberNames)
(snapshot?.members || [])
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
.filter(([, name]) => Boolean(name))
)
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
} }
return evidence.map((item) => { return evidence.map((item) => {
@@ -860,7 +1260,7 @@ export class KnowledgeSearchService {
} }
let session = this.senderEnrichmentSessions.get(retrievalSessionId) let session = this.senderEnrichmentSessions.get(retrievalSessionId)
if (!session) { if (!session) {
session = { lastUsedAt: now, groupSnapshots: new Map() } session = { lastUsedAt: now, groupMemberNames: new Map() }
this.senderEnrichmentSessions.set(retrievalSessionId, session) this.senderEnrichmentSessions.set(retrievalSessionId, session)
} }
session.lastUsedAt = now session.lastUsedAt = now
@@ -872,24 +1272,76 @@ export class KnowledgeSearchService {
return session return session
} }
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> { /**
const enqueuedAt = Date.now() * 交互查询进行中:后台索引会让路。
const run = async (): Promise<T> => { *
const startedAt = Date.now() * 追赶同步会自动遍历上千个会话;如果不让路,一次用户查询会和后台 pass 抢同一个
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt) * Worker 与 WCDB 读取通道,被拖到几十秒 —— 查询不能因为索引 backlog 卡住。
try { */
return await operation() beginInteractiveQuery(): void {
} finally { if (this.interactiveQueryDepth === 0) {
this.wcdbExecutionMsTotal += Date.now() - startedAt this.interactiveIdle = new Promise<void>((resolve) => {
} this.interactiveIdleResolve = resolve
})
} }
const result = this.wcdbReadTail.then(run, run) this.interactiveQueryDepth += 1
// Keep the queue usable after a read failure while returning that failure to its caller. }
this.wcdbReadTail = result.then(
() => undefined, endInteractiveQuery(): void {
() => undefined this.interactiveQueryDepth = Math.max(0, this.interactiveQueryDepth - 1)
) if (this.interactiveQueryDepth === 0) {
return result 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 { private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
@@ -904,7 +1356,10 @@ export class KnowledgeSearchService {
estimatedRemainingMs: null, estimatedRemainingMs: null,
databaseBytes: 0, databaseBytes: 0,
walBytes: 0, walBytes: 0,
shmBytes: 0 shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null,
pass: this.passSnapshot()
} }
} }
@@ -917,20 +1372,29 @@ export class KnowledgeSearchService {
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG }) const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
const current = this.statusByAccount.get(accountId) const current = this.statusByAccount.get(accountId)
const indexing = this.indexing.has(accountId) const indexing = this.indexing.has(accountId)
const pass = this.passProgress
const processedMessages = const processedMessages =
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
const state = indexing // 一遍 pass 结束后,worker 只知道「派生库能不能查」,不知道这一遍是**被取消**还是**出错**。
// 这两个语义只在这里有(worker 侧的 run_state 已经落库),所以由本地 pass 覆盖,
// 避免取消之后又冒充成一个干净的 ready。
const state: KnowledgeRuntimeState = indexing
? remote.indexedMessageCount > 0 ? remote.indexedMessageCount > 0
? 'syncing' ? 'syncing'
: 'building' : 'building'
: remote.state : pass && (pass.phase === 'cancelled' || pass.phase === 'error')
? pass.phase
: remote.state
const status: KnowledgeRuntimeStatus = { const status: KnowledgeRuntimeStatus = {
...remote, ...remote,
state, state,
processedMessages, processedMessages,
totalMessages, totalMessages,
estimatedRemainingMs: null estimatedRemainingMs: null,
// 派生库自己看不到源数据;这里补上源侧最新活跃时间,UI 才能区分 READY 与 FRESH。
sourceLatestAt: this.sourceLatestAt(),
pass: this.passSnapshot()
} }
this.publishStatus(status) this.publishStatus(status)
return status return status
+10
View File
@@ -48,6 +48,16 @@ export class KnowledgeService {
return this.worker.status({ ...request, databaseRoot: this.databaseRoot }) 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> { dispose(): Promise<void> {
return this.worker.dispose() return this.worker.dispose()
} }
+226 -17
View File
@@ -191,6 +191,7 @@ export class KnowledgeStore {
async index( async index(
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & { request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
sourceMessageCount?: number sourceMessageCount?: number
sourceLatestAt?: number
}, },
signal?: AbortSignal, signal?: AbortSignal,
onProgress?: (progress: KnowledgeIndexProgress) => void onProgress?: (progress: KnowledgeIndexProgress) => void
@@ -242,6 +243,9 @@ export class KnowledgeStore {
this.writeMeta('source_message_count', String(request.sourceMessageCount)) this.writeMeta('source_message_count', String(request.sourceMessageCount))
this.refreshStatsSnapshot() this.refreshStatsSnapshot()
} }
if (request.sourceLatestAt !== undefined) {
this.writeMeta('source_latest_at', String(request.sourceLatestAt))
}
this.setRunState('ready') this.setRunState('ready')
return { return {
accountId: this.accountId, accountId: this.accountId,
@@ -293,23 +297,64 @@ export class KnowledgeStore {
} }
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> { getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
this.ensureStatsSnapshot() this.ensureStatsSnapshotForQuery()
const indexedMessageCount = this.readStatNumber('stats_message_count') const indexedMessageCount = this.readStatNumber('stats_message_count')
const indexedChunkCount = this.readStatNumber('stats_chunk_count') const indexedChunkCount = this.readStatNumber('stats_chunk_count')
const runState = this.readMeta('run_state') const runState = this.readMeta('run_state')
return { return {
state: // READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在
runState === 'indexing' // 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。
? 'indexing' // 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。
: runState === 'ready' && indexedChunkCount > 0 state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable',
? 'ready'
: 'unavailable',
indexedMessageCount, indexedMessageCount,
indexedChunkCount, indexedChunkCount,
indexLatestAt: this.readIndexLatestAt(),
timings: emptyKnowledgeSearchTimings() 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 { getRuntimeStatus(): KnowledgeRuntimeStatus {
const search = this.getSearchStatus() const search = this.getSearchStatus()
const storage = this.getStorageStats() const storage = this.getStorageStats()
@@ -320,7 +365,20 @@ export class KnowledgeStore {
const error = this.readMeta('run_error') || undefined const error = this.readMeta('run_error') || undefined
return { return {
accountId: this.accountId, 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, indexedMessageCount: search.indexedMessageCount,
indexedChunkCount: search.indexedChunkCount, indexedChunkCount: search.indexedChunkCount,
sourceMessageCount, sourceMessageCount,
@@ -330,7 +388,10 @@ export class KnowledgeStore {
databaseBytes: storage.databaseBytes, databaseBytes: storage.databaseBytes,
walBytes: storage.walBytes, walBytes: storage.walBytes,
shmBytes: storage.shmBytes, 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() const startedAt = Date.now()
let ftsMs = 0 let ftsMs = 0
let shortTermSearchMs = 0
let messageLoadMs = 0 let messageLoadMs = 0
let chunkExpandMs = 0 let chunkExpandMs = 0
let rankingMs = 0 let rankingMs = 0
@@ -358,6 +420,36 @@ export class KnowledgeStore {
]) ])
).filter(Boolean) ).filter(Boolean)
const senderIds = new Set(query.senderIds || []) 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) { if (!terms.length) {
const messageLoadStartedAt = Date.now() const messageLoadStartedAt = Date.now()
const metadata = this.searchByMetadata(query, conversationIds, senderIds) const metadata = this.searchByMetadata(query, conversationIds, senderIds)
@@ -475,6 +567,7 @@ export class KnowledgeStore {
timings: { timings: {
...emptyKnowledgeSearchTimings(), ...emptyKnowledgeSearchTimings(),
ftsMs, ftsMs,
shortTermSearchMs,
messageLoadMs, messageLoadMs,
chunkExpandMs, chunkExpandMs,
rankingMs, rankingMs,
@@ -703,7 +796,9 @@ export class KnowledgeStore {
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult { searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
const startedAt = Date.now() const startedAt = Date.now()
const statusStartedAt = Date.now()
const status = this.getSearchStatus() const status = this.getSearchStatus()
const statusMs = Date.now() - statusStartedAt
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
const voiceStartedAt = Date.now() const voiceStartedAt = Date.now()
const voiceCoverage = this.getVoiceCoverage(query) const voiceCoverage = this.getVoiceCoverage(query)
@@ -720,6 +815,7 @@ export class KnowledgeStore {
totalMs: workerExecutionMs, totalMs: workerExecutionMs,
globalCountMs: statsRefreshMs, globalCountMs: statsRefreshMs,
voiceCoverageMs, voiceCoverageMs,
statusMs,
workerExecutionMs workerExecutionMs
}, },
conversationRetrieval: measured?.conversationRetrieval, conversationRetrieval: measured?.conversationRetrieval,
@@ -807,6 +903,15 @@ export class KnowledgeStore {
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
ON knowledge_messages (conversation_id, create_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 ( CREATE TABLE IF NOT EXISTS knowledge_chunks (
rowid INTEGER PRIMARY KEY, rowid INTEGER PRIMARY KEY,
chunk_id TEXT NOT NULL UNIQUE, chunk_id TEXT NOT NULL UNIQUE,
@@ -832,10 +937,18 @@ export class KnowledgeStore {
state TEXT NOT NULL, state TEXT NOT NULL,
high_water_time INTEGER, high_water_time INTEGER,
indexed_message_count INTEGER NOT NULL DEFAULT 0, indexed_message_count INTEGER NOT NULL DEFAULT 0,
complete_snapshot INTEGER NOT NULL DEFAULT 0,
last_error TEXT, last_error TEXT,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
) STRICT; ) 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( const messageColumns = new Set(
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) => asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
String(row.name) 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 rebuildStart = Math.max(0, changedAt - chunker.overlapMessages)
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0 const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
@@ -935,7 +1065,9 @@ export class KnowledgeStore {
chunker.version, chunker.version,
'indexing', 'indexing',
null, null,
normalized.length normalized.length,
null,
conversation.completeSnapshot
) )
await this.writeMessageLedger( await this.writeMessageLedger(
conversation.conversationId, conversation.conversationId,
@@ -982,7 +1114,10 @@ export class KnowledgeStore {
chunker.version, chunker.version,
'ready', 'ready',
highWater, highWater,
normalized.length normalized.length,
null,
conversation.completeSnapshot,
conversation.sourceHighWaterTime ?? null
) )
this.database.exec('COMMIT') this.database.exec('COMMIT')
return { chunkCount: chunks.length, updatedChunks: chunks.length } return { chunkCount: chunks.length, updatedChunks: chunks.length }
@@ -1122,22 +1257,37 @@ export class KnowledgeStore {
state: string, state: string,
highWater: number | null, highWater: number | null,
messageCount: number, messageCount: number,
error: string | null = null error: string | null = null,
completeSnapshot = false,
/**
* 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。
* 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉,
* 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。
*/
sourceHighWater: number | null = null
): void { ): void {
this.database this.database
.prepare( .prepare(
`INSERT INTO knowledge_index_state ( `INSERT INTO knowledge_index_state (
conversation_id, account_id, chunker_version, state, high_water_time, conversation_id, account_id, chunker_version, state, high_water_time,
indexed_message_count, last_error, updated_at indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET ON CONFLICT(conversation_id) DO UPDATE SET
account_id = excluded.account_id, account_id = excluded.account_id,
chunker_version = excluded.chunker_version, chunker_version = excluded.chunker_version,
state = excluded.state, state = excluded.state,
high_water_time = excluded.high_water_time, high_water_time = excluded.high_water_time,
indexed_message_count = excluded.indexed_message_count, indexed_message_count = excluded.indexed_message_count,
complete_snapshot = excluded.complete_snapshot,
last_error = excluded.last_error, 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( .run(
conversationId, conversationId,
@@ -1146,11 +1296,33 @@ export class KnowledgeStore {
state, state,
highWater, highWater,
messageCount, messageCount,
completeSnapshot ? 1 : 0,
error, 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 { private readMeta(key: string): string | null {
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
| DbRow | DbRow
@@ -1184,6 +1356,43 @@ export class KnowledgeStore {
this.refreshStatsSnapshot() 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 { private markStatsStale(): void {
this.writeMeta('stats_state', 'stale') this.writeMeta('stats_state', 'stale')
} }
+31 -2
View File
@@ -19,6 +19,7 @@ type WorkerResult =
| KnowledgeCapacityPreflight | KnowledgeCapacityPreflight
| KnowledgeSearchResult | KnowledgeSearchResult
| KnowledgeRuntimeStatus | KnowledgeRuntimeStatus
| { marks: Record<string, number> }
| { removed: true } | { removed: true }
type PendingRequest = { type PendingRequest = {
resolve: (result: WorkerResult) => void resolve: (result: WorkerResult) => void
@@ -36,6 +37,14 @@ export class KnowledgeWorkerHost {
private child: ChildProcess | null = null private child: ChildProcess | null = null
private childStartedAt = 0 private childStartedAt = 0
private readonly pending = new Map<string, PendingRequest>() private readonly pending = new Map<string, PendingRequest>()
/**
* 当前在跑的索引请求 id。
*
* 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 ——
* 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能
* 精确地只中止索引,而**不会**影响任何并发进行的查询请求。
*/
private activeIndexRequestId: string | null = null
constructor(private readonly workerPath: string) {} constructor(private readonly workerPath: string) {}
@@ -43,7 +52,9 @@ export class KnowledgeWorkerHost {
payload: KnowledgeIndexRequest, payload: KnowledgeIndexRequest,
onProgress?: (progress: KnowledgeIndexProgress) => void onProgress?: (progress: KnowledgeIndexProgress) => void
): Promise<KnowledgeIndexResult> { ): 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> { preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
@@ -58,6 +69,21 @@ export class KnowledgeWorkerHost {
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus> 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 }> { remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }> return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
} }
@@ -81,13 +107,15 @@ export class KnowledgeWorkerHost {
private request( private request(
type: KnowledgeWorkerRequest['type'], type: KnowledgeWorkerRequest['type'],
payload: KnowledgeWorkerRequest['payload'], payload: KnowledgeWorkerRequest['payload'],
onProgress?: (progress: KnowledgeIndexProgress) => void onProgress?: (progress: KnowledgeIndexProgress) => void,
onRequestId?: (requestId: string) => void
): Promise<WorkerResult> { ): Promise<WorkerResult> {
const hadWorker = Boolean(this.child?.connected) const hadWorker = Boolean(this.child?.connected)
const child = this.ensureChild() const child = this.ensureChild()
const requestId = randomUUID() const requestId = randomUUID()
const sentAt = Date.now() const sentAt = Date.now()
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload } const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
onRequestId?.(requestId)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.pending.set(requestId, { this.pending.set(requestId, {
resolve, resolve,
@@ -145,6 +173,7 @@ export class KnowledgeWorkerHost {
const pending = this.pending.get(requestId) const pending = this.pending.get(requestId)
if (!pending) return if (!pending) return
this.pending.delete(requestId) this.pending.delete(requestId)
if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null
if (error) pending.reject(error) if (error) pending.reject(error)
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport)) else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
else pending.reject(new Error('Knowledge worker returned no result')) else pending.reject(new Error('Knowledge worker returned no result'))
+26 -1
View File
@@ -114,6 +114,7 @@ async function handleSearch(
evidence: [], evidence: [],
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
indexLatestAt: null,
timings: emptyKnowledgeSearchTimings() timings: emptyKnowledgeSearchTimings()
}, },
workerReceivedAt, workerReceivedAt,
@@ -153,7 +154,9 @@ async function handleStatus(
estimatedRemainingMs: null, estimatedRemainingMs: null,
databaseBytes: 0, databaseBytes: 0,
walBytes: 0, walBytes: 0,
shmBytes: 0 shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null
} }
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable }) send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return 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> { async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
try { try {
if (request.type === 'cancel') { if (request.type === 'cancel') {
@@ -201,6 +222,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleStatus(request, request.payload as KnowledgeStatusRequest) await handleStatus(request, request.payload as KnowledgeStatusRequest)
return return
} }
if (request.type === 'highWater') {
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'index') { if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest) await handleIndex(request, request.payload as KnowledgeIndexRequest)
return return
+97
View File
@@ -0,0 +1,97 @@
/**
* Query Agent CLI 的参数解析。
*
* 单独抽成纯函数便于单测,避免 entry 文件的副作用(app bootstrap / app.whenReady)影响测试。
*
* 两种模式:
* 1. 自然语言:`"问题" [--scope '{...}']` → 走完整 Runtime(LLM tool loop)
* 2. 裸工具诊断:`--tool <name> --args '{...json...}'` → 直接调 Local Query API,
* 用于在没有 GUI 的情况下审计 engine 侧数据(group 解析、覆盖度、Evidence 计数)
*/
import type { QueryCorpusScope } from '../shared/local-query-api'
export function parsePocQuestion(argv: readonly string[]): string {
const args = [...argv]
// npm / pnpm 在复合 script(`build && electron ...`)里会把 `--` 一并追加到命令末尾,
// 于是 separator 会落到 POC 的 argv 里,污染问题正文。
// 只移除开头的这一个独立 separator;问题正文中间的合法 `--` 必须保留。
if (args[0] === '--') args.shift()
return args.join(' ').trim()
}
export type PocInvocation =
| { kind: 'question'; question: string; scope?: QueryCorpusScope; pretty: boolean }
| { kind: 'tool'; toolName: string; args: Record<string, unknown>; pretty: boolean }
export const POC_TOOL_NAMES = [
'query_messages',
'search_messages',
'message_context',
'conversation_overview'
] as const
/**
* 解析 CLI 调用意图。任何参数错误都直接抛出,避免静默变成"提问"。
*/
export function parsePocInvocation(argv: readonly string[]): PocInvocation {
const args = [...argv]
if (args[0] === '--') args.shift()
const pretty = args.includes('--pretty')
const scopeIndex = args.indexOf('--scope')
let scope: QueryCorpusScope | undefined
if (scopeIndex >= 0) {
const rawScope = args[scopeIndex + 1]
if (!rawScope) throw new Error('缺少 --scope <json>')
let parsedScope: unknown
try {
parsedScope = JSON.parse(rawScope)
} catch {
throw new Error('--scope 不是合法 JSON')
}
if (!parsedScope || typeof parsedScope !== 'object' || Array.isArray(parsedScope)) {
throw new Error('--scope 必须是 JSON 对象')
}
const kind = (parsedScope as { kind?: unknown }).kind
if (kind !== 'all' && kind !== 'groups' && kind !== 'contact' && kind !== 'current') {
throw new Error('--scope.kind 必须是 all / groups / contact / current')
}
scope = parsedScope as QueryCorpusScope
if ((kind === 'contact' || kind === 'current') && !(parsedScope as { conversationId?: unknown }).conversationId) {
throw new Error('--scope.kind=contact|current 时必须给 conversationId')
}
}
const toolIndex = args.indexOf('--tool')
if (toolIndex < 0) {
const question =
scopeIndex < 0
? args.join(' ').trim()
: args
.filter((_, index) => index !== scopeIndex && index !== scopeIndex + 1)
.join(' ')
.trim()
return { kind: 'question', question, ...(scope ? { scope } : {}), pretty }
}
const toolName = (args[toolIndex + 1] || '').trim()
if (!POC_TOOL_NAMES.includes(toolName as (typeof POC_TOOL_NAMES)[number])) {
throw new Error(`不支持的诊断工具: ${toolName || '(空)'}(可选:${POC_TOOL_NAMES.join(' / ')})`)
}
const argsIndex = args.indexOf('--args')
const rawArgs = argsIndex >= 0 ? args[argsIndex + 1] : undefined
if (!rawArgs) throw new Error('缺少 --args <json>')
let parsed: unknown
try {
parsed = JSON.parse(rawArgs)
} catch {
throw new Error('--args 不是合法 JSON')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('--args 必须是 JSON 对象')
}
return {
kind: 'tool',
toolName,
args: { ...(parsed as Record<string, unknown>), ...(scope ? { scope } : {}) },
pretty
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Query Agent CLI 入口(adapter)。
*
* 这里**只负责** CLI 专属的部分:argv 解析、HTTP Tool Executor、诊断摘要、stdout JSON。
* Query Agent 的行为完全来自 `src/main/services/query-agent-service.ts` —— CLI 与桌面问问微信 /
* Agent Hub 共用同一份实现,不存在第二套 prompt 或 orchestration。
*/
import './app-data-bootstrap'
import { app } from 'electron'
import { apiTokenStore } from './api-token-store'
import { AIProviderService } from './services/ai-provider-service'
import { QueryAgentService, type QueryAgentToolResult } from './services/query-agent-service'
import { parsePocInvocation } from './query-agent-poc-cli'
import { formatProviderDiagnostics, formatTiming } from './query-agent-poc-report'
import type { QueryCorpusScope } from '../shared/local-query-api'
/** CLI 侧没有联系人列表,范围说明只能是通用文案(生产由 UI 提供带名字的 label)。 */
const describePocScope = (scope: QueryCorpusScope): string => {
if (scope.kind === 'all') return '所有聊天记录'
if (scope.kind === 'groups') return '群聊专属(全部群聊)'
return scope.kind === 'contact' ? '单聊专属(指定联系人)' : '当前会话'
}
const baseUrl = (process.env.TRACEMEMO_QUERY_API_BASE || 'http://127.0.0.1:6131/api/v1').replace(/\/+$/, '')
const invocation = parsePocInvocation(process.argv.slice(2))
async function callQueryApi(name: string, input: Record<string, unknown>): Promise<QueryAgentToolResult> {
const paths: Record<string, string> = {
query_messages: '/query/messages',
search_messages: '/query/search',
message_context: '/query/message-context',
conversation_overview: '/query/conversation-overview'
}
const path = paths[name]
if (!path) throw new Error(`不允许的工具: ${name}`)
const token = apiTokenStore.getTokenForAuthentication()
if (!token) throw new Error('Local Query API Token 不可用')
const response = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(input) })
const payload = await response.json().catch(() => ({})) as Record<string, unknown>
const status = typeof payload.status === 'string' ? payload.status : 'execution_failed'
return { ...payload, status }
}
/**
* CLI 的 Tool Executor:与生产的进程内 executor 等价 —— 语料边界由 Host 注入请求,
* LLM 无法提供它。两者只差 transport(这里走 HTTP,生产走进程内调用)。
*/
const executePocTool = (
name: string,
input: Record<string, unknown>,
context?: { conversationScope?: unknown }
): Promise<QueryAgentToolResult> =>
callQueryApi(
name,
context?.conversationScope ? { ...input, scope: context.conversationScope } : input
)
async function main(): Promise<void> {
await app.whenReady()
if (invocation.kind === 'tool') {
// 裸工具诊断:直接调 Local Query API,用来在没有 GUI 的情况下审计引擎侧真实数据。
const toolResult = await callQueryApi(invocation.toolName, invocation.args)
process.stdout.write(`${JSON.stringify(toolResult, null, 2)}\n`)
app.quit()
return
}
const provider = new AIProviderService()
const service = new QueryAgentService(provider, executePocTool)
const result = await service.run(invocation.question, {
...(invocation.scope ? { conversationScope: { scope: invocation.scope, label: describePocScope(invocation.scope) } } : {})
})
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
// 诊断摘要写 stderr,保持 stdout 仍是纯 JSON,方便管道与脚本消费。
process.stderr.write(formatTiming(result))
process.stderr.write(formatProviderDiagnostics(result, provider.getRuntimeEndpointHost()))
app.quit()
}
void main().catch((error) => {
process.stderr.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)}\n`)
app.quit()
})
+78
View File
@@ -0,0 +1,78 @@
import type { QueryAgentResult } from './services/query-agent-service'
const WIDTH = 24
function row(label: string, value: string): string {
return `${label.padEnd(14)}${value.padStart(10)}`
}
/**
* 人类可读的耗时摘要。
*
* 只做展示,不改变 JSON 输出契约。需要能正确处理:
* - 0 次模型调用(配置缺失等提前返回)
* - 1 次模型调用 + 0 次工具
* - 2 次模型调用 + N 次工具(此时可准确还原"模型 → 工具 → 模型"顺序)
* - 3 次以上(无法逐段归属工具耗时时,明确标注为聚合)
* - 首次模型调用失败 / 末尾模型调用失败
*/
export function formatTiming(result: QueryAgentResult): string {
const durations = result.modelDurationsMs || []
const toolTotalMs = result.toolTotalMs || 0
const toolCount = result.toolCallCount || 0
const lines: string[] = ['', '[Timing]']
if (durations.length === 0) {
lines.push('(未发生模型调用)')
} else if (durations.length <= 2) {
lines.push(row('Model #1', `${durations[0]} ms`))
if (toolCount > 0) lines.push(row(`TM Tools(${toolCount})`, `${toolTotalMs} ms`))
if (durations[1] !== undefined) lines.push(row('Model #2', `${durations[1]} ms`))
} else {
durations.forEach((duration, index) => lines.push(row(`Model #${index + 1}`, `${duration} ms`)))
lines.push(row(`TM Tools(${toolCount})`, `${toolTotalMs} ms`))
lines.push('(3 次以上模型调用:工具耗时按总计展示)')
}
const modelTotalMs = durations.reduce((sum, value) => sum + value, 0)
const totalMs = result.totalMs || 0
const share = (value: number): string =>
totalMs > 0 ? `${((value / totalMs) * 100).toFixed(1)}%` : 'n/a'
lines.push('-'.repeat(WIDTH))
lines.push(row('Total', `${totalMs} ms`))
lines.push('')
lines.push(row('Model total', `${modelTotalMs} ms (${share(modelTotalMs)})`))
lines.push(row('TM tool total', `${toolTotalMs} ms (${share(toolTotalMs)})`))
if (result.error) lines.push(`error: ${result.error}`)
return `${lines.join('\n')}\n`
}
/**
* 请求级诊断。
* 只输出 host 与状态字段;绝不输出 API key / Authorization / 完整 URL / 响应正文。
*/
export function formatProviderDiagnostics(result: QueryAgentResult, host?: string): string {
const lines = [
'',
'[Provider]',
`provider ${result.provider}`,
`model ${result.model}`,
`host ${host || '(unknown)'}`,
`model calls ${result.modelCallCount}`,
`tool calls ${result.toolCallCount}`
]
for (const entry of result.modelDiagnostics || []) {
const parts = [`elapsedMs=${entry.elapsedMs}`]
if (entry.status !== undefined) parts.push(`status=${entry.status}`)
if (entry.contentType) parts.push(`contentType=${entry.contentType}`)
if (entry.timedOut) parts.push('timedOut=true')
if (entry.htmlInsteadOfJson) parts.push('htmlInsteadOfJson=true')
if (entry.errorCode) parts.push(`code=${entry.errorCode}`)
if (entry.errorType) parts.push(`type=${entry.errorType}`)
if (entry.status !== undefined && [502, 503, 504].includes(entry.status)) parts.push('upstreamGateway=true')
lines.push(`attempt #${entry.index} ${parts.join(' ')}`)
if (entry.error) lines.push(` error: ${entry.error}`)
}
return `${lines.join('\n')}\n`
}
+2 -1
View File
@@ -171,10 +171,11 @@ export async function saveGeneratedReport(
contactId: request.contactId, contactId: request.contactId,
contactName: request.contactName, contactName: request.contactName,
contactAvatar: request.contactAvatar, contactAvatar: request.contactAvatar,
source: request.source,
dateRange: request.dateRange, dateRange: request.dateRange,
messageCount: request.messageCount, messageCount: request.messageCount,
generatedAt: timestamp.toISOString(), generatedAt: timestamp.toISOString(),
reportDate: `${year}-${month}-${pad2(timestamp.getDate())}`, reportDate: request.reportDate?.trim() || `${year}-${month}-${pad2(timestamp.getDate())}`,
htmlPath: savedHtmlPath, htmlPath: savedHtmlPath,
pngPath: savedPngPath, pngPath: savedPngPath,
jsonPath, jsonPath,
+41
View File
@@ -0,0 +1,41 @@
import { ipcMain } from 'electron'
import { exportGroupReport } from './group-report-service'
import { reportTemplateService } from './report-template-service'
import { reportTemplateMarketService } from './report-template-market-service'
import type { GroupReportExportRequest } from '../shared/group-report'
let registered = false
export function registerReportTemplateIpc(): void {
if (registered) return
registered = true
ipcMain.handle('report-template:list', () => reportTemplateService.list())
ipcMain.handle('report-template:install', async (_, packagePath: string) => {
try {
return { success: true, template: await reportTemplateService.install(packagePath) }
} catch (error) {
return {
success: false,
code: error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : 'install_failed',
error: error instanceof Error ? error.message : String(error)
}
}
})
ipcMain.handle('report-template:uninstall', async (_, id: string, version: string) => {
try {
await reportTemplateService.uninstall(id, version)
return { success: true }
} catch (error) {
return {
success: false,
code: error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : 'uninstall_failed',
error: error instanceof Error ? error.message : String(error)
}
}
})
ipcMain.handle('report-template-market:list', () => reportTemplateMarketService.listCatalog())
ipcMain.handle('report-template-market:install', (_, id: string, version: string) =>
reportTemplateMarketService.installFromCatalog(String(id || ''), String(version || ''))
)
ipcMain.handle('report:export', (_, request: GroupReportExportRequest) => exportGroupReport(request))
}
+243
View File
@@ -0,0 +1,243 @@
import crypto from 'node:crypto'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { app } from 'electron'
import {
REPORT_TEMPLATE_INTERFACE_VERSION,
REPORT_TEMPLATE_LIMITS,
ReportTemplateError,
type ReportTemplateOperationResult
} from '../shared/report-template-package'
import {
REPORT_TEMPLATE_CATALOG_URL,
type ReportTemplateCatalog,
type ReportTemplateCatalogEntry,
type ReportTemplateCatalogInstallResult,
type ReportTemplateCatalogResult
} from '../shared/report-template-market'
import { reportTemplateService } from './report-template-service'
const CATALOG_MAX_BYTES = 2 * 1024 * 1024
const REQUEST_TIMEOUT_MS = 15_000
const SAFE_ID = /^community\.github\.[a-z0-9][a-z0-9-]{0,38}\.[a-z0-9][a-z0-9-]{0,63}$/
const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
const SHA256 = /^[a-f0-9]{64}$/
const COMMIT = /^[a-f0-9]{40}$/
const RAW_HOST = 'raw.githubusercontent.com'
const RAW_REPOSITORY_PREFIX = '/Wxw-Gu/TraceMemo-Templates/'
const operationError = (fallbackCode: string, error: unknown): ReportTemplateOperationResult => ({
success: false,
code: error instanceof ReportTemplateError ? error.code : fallbackCode,
error: error instanceof Error ? error.message : String(error)
})
const assertAllowedRemoteUrl = (value: unknown, label: string): URL => {
let parsed: URL
try {
parsed = new URL(String(value || ''))
} catch {
throw new ReportTemplateError('invalid_catalog', `${label} URL 无效`)
}
if (
parsed.protocol !== 'https:' ||
parsed.hostname !== RAW_HOST ||
parsed.port !== '' ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash ||
!parsed.pathname.startsWith(RAW_REPOSITORY_PREFIX)
) {
throw new ReportTemplateError('invalid_catalog', `${label} 只允许 GitHub raw HTTPS 地址`)
}
return parsed
}
const assertAllowedPackageUrl = (value: unknown, label: string): URL => {
const parsed = assertAllowedRemoteUrl(value, label)
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 4 || !COMMIT.test(segments[2])) {
throw new ReportTemplateError('invalid_catalog', `${label} 必须固定到模板仓库 commit`)
}
return parsed
}
const remoteCommit = (value: string): string | undefined => {
try {
return new URL(value).pathname.split('/').filter(Boolean)[2]
} catch {
return undefined
}
}
const fetchBytes = async (url: URL, maxBytes: number): Promise<Buffer> => {
const response = await fetch(url, {
headers: { Accept: 'application/json, application/zip, image/png', 'User-Agent': 'TraceMemo' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
redirect: 'error'
})
if (!response.ok) throw new ReportTemplateError('catalog_fetch_failed', `远端请求失败:HTTP ${response.status}`)
const contentLength = Number(response.headers.get('content-length') || 0)
if (contentLength > maxBytes) throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制')
if (!response.body) {
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.length > maxBytes) throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制')
return bytes
}
const reader = response.body.getReader()
const chunks: Buffer[] = []
let total = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel()
throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制')
}
chunks.push(Buffer.from(value))
}
} finally {
reader.releaseLock()
}
return Buffer.concat(chunks, total)
}
const validateCatalogEntry = (value: unknown): ReportTemplateCatalogEntry => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ReportTemplateError('invalid_catalog', '目录条目必须是对象')
}
const entry = value as Record<string, unknown>
const id = String(entry.id || '')
const version = String(entry.version || '')
const interfaceVersion = String(entry.interfaceVersion || '')
const sizeBytes = Number(entry.sizeBytes)
if (!SAFE_ID.test(id) || !SAFE_VERSION.test(version)) {
throw new ReportTemplateError('invalid_catalog', '目录模板 ID 或版本无效')
}
if (interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) {
throw new ReportTemplateError('unsupported_interface', `模板接口版本不兼容:${interfaceVersion}`)
}
if (entry.status !== 'published' || !Number.isSafeInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > REPORT_TEMPLATE_LIMITS.maxCompressedBytes) {
throw new ReportTemplateError('invalid_catalog', `${id}@${version} 目录状态或大小无效`)
}
if (typeof entry.name !== 'string' || !entry.name.trim() || typeof entry.description !== 'string' || typeof entry.author !== 'string') {
throw new ReportTemplateError('invalid_catalog', `${id}@${version} 目录名称或作者无效`)
}
if (!SHA256.test(String(entry.sha256 || ''))) throw new ReportTemplateError('invalid_catalog', `${id}@${version} SHA-256 无效`)
const download = assertAllowedPackageUrl(entry.download, 'download')
const preview = entry.preview === undefined || entry.preview === null ? undefined : assertAllowedPackageUrl(entry.preview, 'preview')
const platform = entry.platform === 'mobile' || entry.platform === 'desktop' || entry.platform === 'default' ? entry.platform : undefined
const tags = Array.isArray(entry.tags) && entry.tags.every((tag) => typeof tag === 'string') ? entry.tags : []
return {
id,
version,
interfaceVersion,
name: entry.name.trim(),
description: entry.description,
author: entry.author,
...(platform ? { platform } : {}),
tags,
license: typeof entry.license === 'string' ? entry.license : '',
minAppVersion: typeof entry.minAppVersion === 'string' ? entry.minAppVersion : null,
download: download.toString(),
sizeBytes,
sha256: String(entry.sha256),
...(preview ? { preview: preview.toString() } : {}),
publishedAt: typeof entry.publishedAt === 'string' ? entry.publishedAt : null,
status: 'published'
}
}
const parseCatalog = (bytes: Buffer): ReportTemplateCatalog => {
let value: unknown
try {
value = JSON.parse(bytes.toString('utf8'))
} catch {
throw new ReportTemplateError('invalid_catalog', '远端模板目录不是有效 JSON')
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ReportTemplateError('invalid_catalog', '远端模板目录格式无效')
}
const raw = value as Record<string, unknown>
if (raw.schemaVersion !== '1' || raw.status !== 'published' || !Array.isArray(raw.templates) || raw.templates.length > 100) {
throw new ReportTemplateError('invalid_catalog', '远端模板目录版本或状态无效')
}
const seen = new Set<string>()
const templates = raw.templates.map(validateCatalogEntry)
for (const entry of templates) {
const key = `${entry.id}@${entry.version}`
if (seen.has(key)) throw new ReportTemplateError('invalid_catalog', `目录包含重复模板:${key}`)
seen.add(key)
}
const source = raw.source && typeof raw.source === 'object' && !Array.isArray(raw.source)
? raw.source as Record<string, unknown>
: undefined
if (
!source ||
source.repository !== 'Wxw-Gu/TraceMemo-Templates' ||
!COMMIT.test(String(source.commit || ''))
) {
throw new ReportTemplateError('invalid_catalog', '目录 source.repository 或 source.commit 无效')
}
for (const entry of templates) {
if (
remoteCommit(entry.download) !== source.commit ||
(entry.preview !== undefined && remoteCommit(entry.preview) !== source.commit)
) {
throw new ReportTemplateError('invalid_catalog', `${entry.id}@${entry.version} 未固定到目录 source.commit`)
}
}
return {
schemaVersion: '1',
...(typeof raw.generatedAt === 'string' ? { generatedAt: raw.generatedAt } : {}),
...(source ? { source: { ...(typeof source.repository === 'string' ? { repository: source.repository } : {}), ...(typeof source.commit === 'string' ? { commit: source.commit } : {}) } } : {}),
status: 'published',
templates
}
}
const fetchCatalog = async (): Promise<ReportTemplateCatalog> => {
const url = assertAllowedRemoteUrl(REPORT_TEMPLATE_CATALOG_URL, 'catalog')
return parseCatalog(await fetchBytes(url, CATALOG_MAX_BYTES))
}
export class ReportTemplateMarketService {
async listCatalog(): Promise<ReportTemplateCatalogResult> {
try {
return { success: true, catalog: await fetchCatalog() }
} catch (error) {
return operationError('catalog_fetch_failed', error) as ReportTemplateCatalogResult
}
}
async installFromCatalog(id: string, version: string): Promise<ReportTemplateCatalogInstallResult> {
let temporaryDirectory: string | undefined
try {
const catalog = await fetchCatalog()
const entry = catalog.templates.find((candidate) => candidate.id === id && candidate.version === version)
if (!entry) return { success: false, code: 'catalog_template_not_found', error: `远端目录不存在:${id}@${version}` }
const bytes = await fetchBytes(assertAllowedPackageUrl(entry.download, 'download'), REPORT_TEMPLATE_LIMITS.maxCompressedBytes)
const digest = crypto.createHash('sha256').update(bytes).digest('hex')
if (bytes.length !== entry.sizeBytes || digest !== entry.sha256) {
return { success: false, code: 'download_integrity_failed', error: `模板包校验失败:${id}@${version}`, catalogEntry: entry }
}
temporaryDirectory = await fs.mkdtemp(path.join(app.getPath('temp'), 'tracememo-template-market-'))
const packagePath = path.join(temporaryDirectory, `${id}-${version}.zip`)
await fs.writeFile(packagePath, bytes, { flag: 'wx' })
const template = await reportTemplateService.install(packagePath, {
id: entry.id,
version: entry.version
})
return { success: true, template, catalogEntry: entry }
} catch (error) {
return { ...(operationError('market_install_failed', error) as ReportTemplateCatalogInstallResult) }
} finally {
if (temporaryDirectory) await fs.rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined)
}
}
}
export const reportTemplateMarketService = new ReportTemplateMarketService()
+403
View File
@@ -0,0 +1,403 @@
import crypto from 'node:crypto'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { Open } from 'unzipper'
import { parseFragment } from 'parse5'
import * as csstree from 'css-tree'
import { app } from 'electron'
import {
DEFAULT_REPORT_TEMPLATE,
REPORT_TEMPLATES,
type ReportTemplateDefinition
} from '../shared/report-templates'
import {
REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS,
REPORT_TEMPLATE_ALLOWED_ATTRS,
REPORT_TEMPLATE_ALLOWED_TAGS,
REPORT_TEMPLATE_INTERFACE_VERSION,
REPORT_TEMPLATE_LIMITS,
REPORT_TEMPLATE_PACKAGE_PROTOCOL_VERSION,
REPORT_TEMPLATE_PLACEHOLDERS,
type InstalledReportTemplate,
type ReportTemplateManifest,
type ReportTemplateRef,
ReportTemplateError
} from '../shared/report-template-package'
const INDEX_FILE = 'index.json'
const INSTALLED_DIR = 'installed'
const STAGING_DIR = 'staging'
const SAFE_ID = /^community\.github\.[a-z0-9][a-z0-9-]{0,38}\.[a-z0-9][a-z0-9-]{0,63}$/
const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
const SAFE_RELATIVE_PATH = /^[^\\/][^:]*$/
interface TemplateIndex {
version: 1
templates: InstalledReportTemplate[]
}
interface EntryInfo {
path: string
type: 'file' | 'directory'
size: number
entry: Awaited<ReturnType<typeof Open.file>>['files'][number]
}
function rootPath(): string {
return path.join(app.getPath('userData'), 'report-templates')
}
function isWithin(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate)
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
}
function validateRelativePath(value: unknown, label: string): string {
if (typeof value !== 'string' || !value || !SAFE_RELATIVE_PATH.test(value)) {
throw new ReportTemplateError('invalid_path', `${label} 必须是包内相对路径`)
}
const normalized = path.posix.normalize(value.replace(/\\/g, '/'))
if (normalized === '.' || normalized.startsWith('../') || normalized.includes('/../') || path.posix.isAbsolute(normalized)) {
throw new ReportTemplateError('invalid_path', `${label} 不能越出模板包目录`)
}
return normalized
}
function parseManifest(value: unknown): ReportTemplateManifest {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ReportTemplateError('invalid_manifest', 'manifest.json 必须是对象')
}
const manifest = value as Record<string, unknown>
const author = manifest.author as Record<string, unknown> | undefined
const capture = manifest.capture as Record<string, unknown> | undefined
const license = manifest.license as Record<string, unknown> | undefined
if (manifest.protocolVersion !== REPORT_TEMPLATE_PACKAGE_PROTOCOL_VERSION) {
throw new ReportTemplateError('unsupported_protocol', '模板包协议版本不受支持')
}
if (manifest.kind !== 'daily-report' || typeof manifest.id !== 'string' || !SAFE_ID.test(manifest.id)) {
throw new ReportTemplateError('invalid_manifest', '模板类型或 ID 无效')
}
if (typeof manifest.name !== 'string' || !manifest.name.trim() || !author || typeof author.name !== 'string') {
throw new ReportTemplateError('invalid_manifest', '模板名称和作者不能为空')
}
if (typeof manifest.templateVersion !== 'string' || !SAFE_VERSION.test(manifest.templateVersion)) {
throw new ReportTemplateError('invalid_version', 'templateVersion 必须是 semver')
}
if (manifest.interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) {
throw new ReportTemplateError('unsupported_interface', '占位符接口版本不受支持')
}
const entry = validateRelativePath(manifest.entry, 'entry')
const preview = manifest.preview === undefined ? undefined : validateRelativePath(manifest.preview, 'preview')
const captureWidth = capture?.width
const captureMaxWidth = capture?.maxWidth
const captureMaxHeight = capture?.maxHeight
if (!capture || !Number.isInteger(captureWidth) || !Number.isInteger(captureMaxWidth) || !Number.isInteger(captureMaxHeight)) {
throw new ReportTemplateError('invalid_manifest', 'capture 尺寸无效')
}
if ((captureWidth as number) < 320 || (captureWidth as number) > 1920 || (captureMaxWidth as number) < (captureWidth as number) || (captureMaxWidth as number) > 1920 || (captureMaxHeight as number) < 800 || (captureMaxHeight as number) > 20000) {
throw new ReportTemplateError('invalid_manifest', 'capture 尺寸超出允许范围')
}
if (!license || typeof license.spdx !== 'string' || !license.spdx.trim()) {
throw new ReportTemplateError('invalid_manifest', '必须声明 license.spdx')
}
return {
protocolVersion: manifest.protocolVersion,
kind: 'daily-report',
id: manifest.id,
name: manifest.name.trim(),
author: { name: author.name.trim(), ...(typeof author.homepage === 'string' ? { homepage: author.homepage } : {}) },
templateVersion: manifest.templateVersion,
interfaceVersion: manifest.interfaceVersion,
entry,
...(preview ? { preview } : {}),
capture: { width: captureWidth as number, maxWidth: captureMaxWidth as number, maxHeight: captureMaxHeight as number },
license: { spdx: license.spdx.trim(), ...(typeof license.notice === 'string' ? { notice: license.notice } : {}) },
...(typeof manifest.minAppVersion === 'string' ? { minAppVersion: manifest.minAppVersion } : {}),
...(manifest.platform === 'mobile' || manifest.platform === 'desktop' || manifest.platform === 'default' ? { platform: manifest.platform } : {})
}
}
function validateCss(css: string, fileName: string): void {
if (Buffer.byteLength(css, 'utf8') > REPORT_TEMPLATE_LIMITS.maxCssBytes) {
throw new ReportTemplateError('file_too_large', `${fileName} 超过 CSS 大小限制`)
}
let ast: csstree.CssNode
try {
ast = csstree.parse(css, { positions: false })
} catch (error) {
throw new ReportTemplateError('invalid_css', `${fileName} CSS 解析失败:${String(error)}`)
}
csstree.walk(ast, (node) => {
if (node.type === 'Atrule' && node.name.toLowerCase() === 'import') {
throw new ReportTemplateError('unsafe_css', `${fileName} 不允许 @import`)
}
if (node.type === 'Url' || (node.type === 'Function' && node.name.toLowerCase() === 'url')) {
const raw = csstree.generate(node)
if (/^(?:url\()?\s*(?:https?:|file:|data:|javascript:)/i.test(raw.trim())) {
throw new ReportTemplateError('unsafe_url', `${fileName} 包含危险资源 URL`)
}
}
})
}
export function validateReportTemplateHtml(html: string, fileName = 'template.html'): void {
if (Buffer.byteLength(html, 'utf8') > REPORT_TEMPLATE_LIMITS.maxHtmlBytes) {
throw new ReportTemplateError('file_too_large', `${fileName} 超过 HTML 大小限制`)
}
const placeholders = [...html.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)].map((match) => match[1])
for (const key of placeholders) {
if (!REPORT_TEMPLATE_PLACEHOLDERS[key]) throw new ReportTemplateError('unknown_placeholder', `不支持占位符 {{${key}}}`)
}
const fragment = parseFragment(html)
const visit = (node: { nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }): void => {
const tag = node.nodeName.toLowerCase()
if (tag === '#text' || tag === '#comment' || tag === '#document-fragment') {
for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child)
return
}
if (!REPORT_TEMPLATE_ALLOWED_TAGS.has(tag)) throw new ReportTemplateError('unsafe_html', `${fileName} 不允许标签 <${tag}>`)
for (const attr of node.attrs || []) {
const name = attr.name.toLowerCase()
if (!REPORT_TEMPLATE_ALLOWED_ATTRS.has(name) || name.startsWith('on')) throw new ReportTemplateError('unsafe_html', `${fileName} 不允许属性 ${attr.name}`)
if (/\{\{[A-Z0-9_]+\}\}/.test(attr.value)) {
const keys = [...attr.value.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)].map((match) => match[1])
if (name !== 'class' || keys.some((key) => REPORT_TEMPLATE_PLACEHOLDERS[key] !== 'class')) {
throw new ReportTemplateError('invalid_placeholder_context', `占位符不能出现在 ${name} 属性中`)
}
}
if (name === 'src') {
const assetPath = path.posix.normalize(attr.value.replace(/\\/g, '/'))
if (!assetPath.startsWith('assets/') || !REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS.has(path.posix.extname(assetPath).toLowerCase())) {
throw new ReportTemplateError('unsafe_url', `${fileName} 只允许 assets/ 下的图片资源`)
}
}
if (['href', 'action', 'poster'].includes(name)) throw new ReportTemplateError('unsafe_url', `${fileName} 不允许 URL 属性`)
}
if (tag === 'style') {
const text = (node.childNodes || []).map((child) => (child as { value?: string }).value || '').join('')
if (/\{\{[A-Z0-9_]+\}\}/.test(text)) throw new ReportTemplateError('invalid_placeholder_context', '占位符不能出现在 style 中')
validateCss(text, fileName)
}
for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child)
}
visit(fragment)
}
function collectTemplateAssetRefs(html: string): string[] {
const refs: string[] = []
const fragment = parseFragment(html)
const visit = (node: { nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }): void => {
if (node.nodeName.toLowerCase() === 'img') {
const src = node.attrs?.find((attr) => attr.name.toLowerCase() === 'src')?.value
if (src) refs.push(path.posix.normalize(src.replace(/\\/g, '/')))
}
for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child)
}
visit(fragment)
return refs
}
async function readIndex(directory: string): Promise<TemplateIndex> {
try {
const value = JSON.parse(await fs.readFile(path.join(directory, INDEX_FILE), 'utf8')) as Partial<TemplateIndex>
if (value.version !== 1 || !Array.isArray(value.templates)) throw new Error('invalid index')
return { version: 1, templates: value.templates }
} catch {
return { version: 1, templates: [] }
}
}
async function writeIndex(directory: string, index: TemplateIndex): Promise<void> {
const temporary = `${path.join(directory, INDEX_FILE)}.tmp-${process.pid}-${Date.now()}`
await fs.writeFile(temporary, JSON.stringify(index, null, 2), 'utf8')
await fs.rename(temporary, path.join(directory, INDEX_FILE))
}
function builtInTemplate(template: ReportTemplateDefinition): InstalledReportTemplate {
const resourceRoot = path.join(process.resourcesPath || process.cwd(), 'resources')
const entryPath = path.join(resourceRoot, template.resourceFile)
return {
id: template.id,
version: 'builtin',
interfaceVersion: REPORT_TEMPLATE_INTERFACE_VERSION,
source: 'builtin',
name: template.name,
author: 'TraceMemo',
entryPath,
capture: { width: template.captureWidth, maxWidth: template.maxCaptureWidth, maxHeight: 20000 },
license: { spdx: 'NOASSERTION' }
}
}
export class ReportTemplateService {
private readonly directory = rootPath()
async list(): Promise<InstalledReportTemplate[]> {
await fs.mkdir(path.join(this.directory, INSTALLED_DIR), { recursive: true })
const index = await readIndex(this.directory)
return [builtInTemplate(DEFAULT_REPORT_TEMPLATE), ...REPORT_TEMPLATES.map(builtInTemplate), ...index.templates]
}
async resolve(ref: ReportTemplateRef): Promise<InstalledReportTemplate> {
const id = String(ref?.id || '').trim()
if (!id) throw new ReportTemplateError('missing_template', '缺少模板 ID')
const templates = await this.list()
const candidates = templates.filter((template) => template.id === id)
if (!candidates.length) throw new ReportTemplateError('template_not_found', `模板不存在:${id}`)
if (candidates[0].source === 'builtin') {
throw new ReportTemplateError('builtin_template_ref', `外部模板引用不能使用内置模板:${id}`)
}
const found = ref.version ? candidates.find((template) => template.version === ref.version) : candidates.sort((a, b) => b.version.localeCompare(a.version))[0]
if (!found) throw new ReportTemplateError('template_version_not_found', `模板版本不存在:${id}@${ref.version}`)
if (found.interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) throw new ReportTemplateError('unsupported_interface', `模板接口版本不兼容:${found.interfaceVersion}`)
return found
}
async install(packagePath: string, expectedRef?: ReportTemplateRef): Promise<InstalledReportTemplate> {
const stat = await fs.stat(packagePath)
if (!stat.isFile() || stat.size > REPORT_TEMPLATE_LIMITS.maxCompressedBytes) throw new ReportTemplateError('package_too_large', '模板 ZIP 超过大小限制')
const archive = await Open.file(packagePath)
if (archive.files.length === 0 || archive.files.length > REPORT_TEMPLATE_LIMITS.maxFiles) throw new ReportTemplateError('too_many_files', '模板包文件数量无效')
const seen = new Set<string>()
const entries: EntryInfo[] = []
let declaredTotal = 0
for (const entry of archive.files) {
const normalized = validateRelativePath(entry.path, 'ZIP 条目')
const collisionKey = normalized.toLowerCase()
if (seen.has(collisionKey)) throw new ReportTemplateError('duplicate_entry', `ZIP 条目规范化后重复:${normalized}`)
seen.add(collisionKey)
if (entry.type !== 'Directory' && entry.type !== 'File') throw new ReportTemplateError('unsafe_entry', `ZIP 条目类型不受支持:${normalized}`)
const type = entry.type === 'Directory' ? 'directory' : 'file'
const size = Number(entry.uncompressedSize || 0)
if (type === 'file' && size > REPORT_TEMPLATE_LIMITS.maxFileBytes) throw new ReportTemplateError('file_too_large', `ZIP 条目过大:${normalized}`)
if (type === 'file') declaredTotal += size
if (declaredTotal > REPORT_TEMPLATE_LIMITS.maxExtractedBytes) throw new ReportTemplateError('package_too_large', '模板包解压体积超过限制')
if (type === 'file' && (normalized.endsWith('.html') || normalized.endsWith('.css')) && size > REPORT_TEMPLATE_LIMITS.maxHtmlBytes) throw new ReportTemplateError('file_too_large', `模板文件过大:${normalized}`)
entries.push({ path: normalized, type, size, entry })
}
const manifestEntry = entries.find((entry) => entry.path === 'manifest.json' && entry.type === 'file')
if (!manifestEntry) throw new ReportTemplateError('invalid_manifest', '模板包缺少 manifest.json')
const manifest = parseManifest(JSON.parse((await manifestEntry.entry.buffer()).toString('utf8')))
if (manifest.id.startsWith('builtin.')) throw new ReportTemplateError('reserved_id', '外部模板不能使用内置命名空间')
if (
expectedRef &&
(manifest.id !== expectedRef.id || manifest.templateVersion !== expectedRef.version)
) {
throw new ReportTemplateError(
'catalog_manifest_mismatch',
`模板包 manifest 与目录条目不一致:${manifest.id}@${manifest.templateVersion}`
)
}
const entry = entries.find((item) => item.path === manifest.entry && item.type === 'file')
if (!entry) throw new ReportTemplateError('missing_entry', 'manifest.entry 文件不存在')
const html = (await entry.entry.buffer()).toString('utf8')
validateReportTemplateHtml(html, manifest.entry)
const assetPaths = new Set(entries.filter((item) => item.type === 'file').map((item) => item.path))
for (const asset of collectTemplateAssetRefs(html)) {
if (!assetPaths.has(asset)) throw new ReportTemplateError('missing_asset', `模板图片资源不存在:${asset}`)
}
const preview = manifest.preview ? entries.find((item) => item.path === manifest.preview && item.type === 'file') : undefined
if (manifest.preview && !preview) throw new ReportTemplateError('missing_preview', 'manifest.preview 文件不存在')
for (const item of entries.filter((candidate) => candidate.type === 'file')) {
const ext = path.extname(item.path).toLowerCase()
if (item.path !== 'manifest.json' && item.path !== manifest.entry && item.path !== manifest.preview && !REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS.has(ext)) {
throw new ReportTemplateError('unsupported_resource', `不支持的模板资源:${item.path}`)
}
}
const staging = path.join(this.directory, STAGING_DIR, `${process.pid}-${Date.now()}-${crypto.randomUUID()}`)
await fs.mkdir(staging, { recursive: true })
try {
let actualTotal = 0
for (const item of entries) {
const destination = path.resolve(staging, item.path)
if (!isWithin(staging, destination)) throw new ReportTemplateError('invalid_path', `ZIP 条目越界:${item.path}`)
if (item.type === 'directory') {
await fs.mkdir(destination, { recursive: true })
continue
}
await fs.mkdir(path.dirname(destination), { recursive: true })
const stream = item.entry.stream()
const chunks: Buffer[] = []
for await (const chunk of stream) {
const buffer = Buffer.from(chunk as Uint8Array)
actualTotal += buffer.length
if (actualTotal > REPORT_TEMPLATE_LIMITS.maxExtractedBytes || buffer.length > REPORT_TEMPLATE_LIMITS.maxFileBytes) throw new ReportTemplateError('package_too_large', '模板包实际解压体积超过限制')
chunks.push(buffer)
}
await fs.writeFile(destination, Buffer.concat(chunks))
const fileStat = await fs.lstat(destination)
if (!fileStat.isFile()) throw new ReportTemplateError('invalid_file', `模板文件类型无效:${item.path}`)
}
const target = path.join(this.directory, INSTALLED_DIR, manifest.id, manifest.templateVersion)
const existingIndex = await readIndex(this.directory)
const existing = existingIndex.templates.find((item) => item.id === manifest.id && item.version === manifest.templateVersion)
const digest = crypto.createHash('sha256').update(await fs.readFile(packagePath)).digest('hex')
if (existing) {
if (existing.sha256 !== digest) throw new ReportTemplateError('version_conflict', `模板版本已存在但内容不同:${manifest.id}@${manifest.templateVersion}`)
return existing
}
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(staging, target)
const installed: InstalledReportTemplate = {
id: manifest.id, version: manifest.templateVersion, interfaceVersion: manifest.interfaceVersion,
source: 'installed', name: manifest.name, author: manifest.author.name,
entryPath: path.join(target, manifest.entry), ...(manifest.preview ? { previewPath: path.join(target, manifest.preview) } : {}),
capture: manifest.capture, license: manifest.license, packagePath, sha256: digest, installedAt: new Date().toISOString()
}
existingIndex.templates = [...existingIndex.templates.filter((item) => !(item.id === installed.id && item.version === installed.version)), installed]
await writeIndex(this.directory, existingIndex)
return installed
} finally {
// 同版本幂等返回也必须清理 staging,避免重复安装逐渐堆积临时目录。
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined)
}
}
async uninstall(id: string, version: string): Promise<void> {
if (!SAFE_ID.test(id) || !SAFE_VERSION.test(version)) {
throw new ReportTemplateError('invalid_template_ref', '模板 ID 或版本无效')
}
const index = await readIndex(this.directory)
const target = index.templates.find((item) => item.id === id && item.version === version)
if (!target) throw new ReportTemplateError('template_not_found', `模板版本不存在:${id}@${version}`)
await fs.rm(path.join(this.directory, INSTALLED_DIR, id, version), { recursive: true, force: true })
index.templates = index.templates.filter((item) => item !== target)
await writeIndex(this.directory, index)
}
async recover(): Promise<void> {
const installedRoot = path.join(this.directory, INSTALLED_DIR)
await fs.mkdir(installedRoot, { recursive: true })
const previous = await readIndex(this.directory)
const recovered: InstalledReportTemplate[] = []
for (const id of await fs.readdir(installedRoot)) {
for (const version of await fs.readdir(path.join(installedRoot, id))) {
const entryPath = path.join(installedRoot, id, version)
try {
const manifest = parseManifest(JSON.parse(await fs.readFile(path.join(entryPath, 'manifest.json'), 'utf8')))
const recoveredEntryPath = path.join(entryPath, manifest.entry)
const entryStat = await fs.lstat(recoveredEntryPath)
if (!entryStat.isFile()) continue
const candidate = previous.templates.find((item) => item.id === manifest.id && item.version === manifest.templateVersion)
recovered.push(candidate && candidate.source === 'installed'
? { ...candidate, entryPath: recoveredEntryPath, previewPath: manifest.preview ? path.join(entryPath, manifest.preview) : undefined }
: {
id: manifest.id,
version: manifest.templateVersion,
interfaceVersion: manifest.interfaceVersion,
source: 'installed',
name: manifest.name,
author: manifest.author.name,
entryPath: recoveredEntryPath,
...(manifest.preview ? { previewPath: path.join(entryPath, manifest.preview) } : {}),
capture: manifest.capture,
license: manifest.license
})
} catch { /* 忽略损坏的安装目录,保留其他模板 */ }
}
}
await writeIndex(this.directory, { version: 1, templates: recovered })
}
}
export const reportTemplateService = new ReportTemplateService()
+44
View File
@@ -0,0 +1,44 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import path from 'node:path'
const userData = process.env.TRACEMEMO_TEMPLATE_TEST_USER_DATA
if (!userData) throw new Error('TRACEMEMO_TEMPLATE_TEST_USER_DATA is required')
app.setPath('userData', userData)
app.setPath('logs', path.join(userData, 'logs'))
app.whenReady().then(async () => {
const { registerReportTemplateIpc } = await import('./report-template-ipc')
const { reportTemplateService } = await import('./report-template-service')
const {
deleteGeneratedReport,
listGeneratedReports,
prepareGeneratedReportTemplateSwitch,
saveGeneratedReport,
updateGeneratedReportTemplate
} = await import('./report-history-service')
const { exportGroupReportSnapshot, extractGroupReportRenderSnapshot } = await import('./group-report-service')
await reportTemplateService.recover()
registerReportTemplateIpc()
ipcMain.handle('report:listGenerated', () => listGeneratedReports())
ipcMain.handle('report:saveGenerated', (_, request) => saveGeneratedReport(request))
ipcMain.handle('report:updateGeneratedTemplate', (_, request) => updateGeneratedReportTemplate(request))
ipcMain.handle('report:prepareTemplateSwitch', (_, request: { reportId: string }) =>
prepareGeneratedReportTemplateSwitch(request.reportId, extractGroupReportRenderSnapshot)
)
ipcMain.handle('report:deleteGenerated', (_, reportId: string) => deleteGeneratedReport(reportId))
ipcMain.handle('report:exportSnapshot', (_, request) => exportGroupReportSnapshot(request))
const window = new BrowserWindow({
width: 1200,
height: 800,
show: true,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
await window.loadFile(path.join(__dirname, '../renderer/index.html'))
})
app.on('window-all-closed', () => app.quit())
+140 -25
View File
@@ -1,7 +1,16 @@
import { randomUUID } from 'crypto'
import type { Contact, Message } from '../../shared/types' import type { Contact, Message } from '../../shared/types'
import type { GroupDailyReport, GroupReportMetadata } from '../../shared/group-report'
import { exportGroupReport } from '../group-report-service' import { exportGroupReport } from '../group-report-service'
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service' import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
import { AIProviderService } from './ai-provider-service' import { AIProviderService } from './ai-provider-service'
import type {
ScheduledReportMemberNameMode,
ScheduledReportMessageType
} from '../../shared/scheduled-report'
import type { ScheduledReportExecutionStage } from '../../shared/scheduled-report'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
import { resolveMemberName } from '../../shared/member-names'
import { import {
buildGroupReportInput, buildGroupReportInput,
getSummaryDateRange, getSummaryDateRange,
@@ -16,42 +25,89 @@ const aiProvider = new AIProviderService()
export interface AgentGroupReportRequest { export interface AgentGroupReportRequest {
group: string group: string
range?: SummaryDateRange range?: SummaryDateRange | 'recent24h'
messageTypes?: ScheduledReportMessageType[]
templateId?: SelectableReportTemplateId
memberNameMode?: ScheduledReportMemberNameMode
timeoutSeconds?: number
} }
export interface AgentGroupReportResult { export interface AgentGroupReportResult {
success: boolean success: boolean
groupName?: string groupName?: string
htmlPath?: string
pngPath?: string pngPath?: string
messageCount?: number messageCount?: number
reportSnapshot?: GroupDailyReport
reportMetadata?: GroupReportMetadata
modelName?: string
tokenUsage?: {
input?: number
output?: number
total?: number
estimated?: boolean
}
duration?: number
error?: string error?: string
errorCode?: string
errorStage?: ScheduledReportExecutionStage
errorStatus?: number
errorType?: string
} }
export async function generateAgentGroupReport( export async function generateAgentGroupReport(
request: AgentGroupReportRequest request: AgentGroupReportRequest
): Promise<AgentGroupReportResult> { ): Promise<AgentGroupReportResult> {
const startedAt = Date.now()
const query = String(request.group || '') const query = String(request.group || '')
.trim() .trim()
.replace(/群聊?$/, '') .replace(/群聊?$/, '')
.trim() .trim()
if (!query) return { success: false, error: '缺少群聊名称' } if (!query) return reportFailure('缺少群聊名称', 'data', 'CHAT_NOT_FOUND')
const contact = resolveMd5(query) const contact = resolveMd5(query)
if (!contact) return { success: false, error: `没有找到群聊“${query}”` } if (!contact) return reportFailure(`没有找到群聊“${query}”`, 'data', 'CHAT_NOT_FOUND')
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) { if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
return { success: false, error: `“${query}”不是群聊` } return reportFailure(`“${query}”不是群聊`, 'data', 'CHAT_NOT_FOUND')
} }
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today' const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
const { startTime, endTime } = getSummaryDateRange(range) const { startTime, endTime } =
request.range === 'recent24h'
? (() => {
const now = Math.floor(Date.now() / 1000)
return { startTime: now - 86400, endTime: now }
})()
: getSummaryDateRange(range)
let messages = listMessages(contact.md5, startTime, endTime) as Message[] let messages = listMessages(contact.md5, startTime, endTime) as Message[]
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' } if (!messages.length) return reportFailure('所选时间范围没有可总结的消息', 'data', 'NO_MESSAGES')
const messageTypeMap: Record<ScheduledReportMessageType, string[]> = {
text: ['普通文本'],
image: ['图片'],
sticker: ['表情包'],
video: ['视频'],
voice: ['语音'],
share: ['分享消息', '名片', '位置', '通话'],
system: ['系统消息']
}
const selectedTypes = new Set(
(request.messageTypes?.length
? request.messageTypes
: (Object.keys(messageTypeMap) as ScheduledReportMessageType[])
).flatMap((type) => messageTypeMap[type] || [])
)
messages = messages.filter((message) => selectedTypes.has(message.type))
if (!messages.length) return reportFailure('所选时间范围没有可总结的消息', 'data', 'NO_MESSAGES')
const snapshot = getGroupSnapshot(contact.md5) const snapshot = getGroupSnapshot(contact.md5)
if (snapshot) { if (snapshot) {
const members = new Map( const members = new Map(
snapshot.members.map((member) => [ snapshot.members.map((member) => [
member.wxid, member.wxid,
{ name: member.nickname, avatar: member.avatar } {
name: resolveMemberName(member, request.memberNameMode || 'groupNickname'),
avatar: member.avatar
}
]) ])
) )
messages = messages.map((message) => { messages = messages.map((message) => {
@@ -64,11 +120,23 @@ export async function generateAgentGroupReport(
} }
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full') const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
const ai = await aiProvider.chat([ const runtime = aiProvider.getRuntimeConfig()
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT }, const sessionId = randomUUID()
{ role: 'user', content: input.prompt } const ai = await aiProvider.chat(
]) [
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' } { role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
{ sessionId, timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
)
if (!ai.success || !ai.data) {
return {
...reportFailure(ai.error || 'AI 总结失败', 'ai', ai.errorCode),
...(ai.errorStatus !== undefined ? { errorStatus: ai.errorStatus } : {}),
...(ai.errorType ? { errorType: ai.errorType } : {})
}
}
let tokenUsage = ai.usage
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> => const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
parseGroupDailyReport( parseGroupDailyReport(
raw, raw,
@@ -82,34 +150,81 @@ export async function generateAgentGroupReport(
try { try {
report = parseReport(ai.data) report = parseReport(ai.data)
} catch (parseError) { } catch (parseError) {
const repaired = await aiProvider.chat([ const repaired = await aiProvider.chat(
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT }, [
{ role: 'user', content: ai.data } { role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
]) { role: 'user', content: ai.data }
],
{ sessionId, timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
)
if (!repaired.success || !repaired.data) { if (!repaired.success || !repaired.data) {
const cause = parseError instanceof Error ? parseError.message : String(parseError) const cause = parseError instanceof Error ? parseError.message : String(parseError)
return { return {
success: false, ...reportFailure(
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})` `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`,
'ai',
repaired.errorCode
),
...(repaired.errorStatus !== undefined ? { errorStatus: repaired.errorStatus } : {}),
...(repaired.errorType ? { errorType: repaired.errorType } : {})
} }
} }
tokenUsage = mergeTokenUsage(tokenUsage, repaired.usage)
try { try {
report = parseReport(repaired.data) report = parseReport(repaired.data)
} catch (repairError) { } catch (repairError) {
return { return reportFailure(
success: false, repairError instanceof Error ? repairError.message : String(repairError),
error: repairError instanceof Error ? repairError.message : String(repairError) 'report',
} 'REPORT_GENERATION_FAILED'
)
} }
} }
const exported = await exportGroupReport({ report, metadata: input.metadata }) const exported = await exportGroupReport({
report,
metadata: input.metadata,
templateId: request.templateId
})
if (!exported.success || !exported.pngPath) { if (!exported.success || !exported.pngPath) {
return { success: false, error: exported.error || '总结图片生成失败' } return reportFailure(exported.error || '总结图片生成失败', 'report', 'REPORT_GENERATION_FAILED')
} }
return { return {
success: true, success: true,
groupName: input.metadata.groupName, groupName: input.metadata.groupName,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath, pngPath: exported.pngPath,
messageCount: messages.length messageCount: messages.length,
reportSnapshot: report,
reportMetadata: input.metadata,
modelName: runtime.modelName || runtime.model,
tokenUsage,
duration: Date.now() - startedAt
}
}
function mergeTokenUsage(
first: AgentGroupReportResult['tokenUsage'],
second: AgentGroupReportResult['tokenUsage']
): AgentGroupReportResult['tokenUsage'] {
if (!first) return second
if (!second) return first
return {
input: (first.input || 0) + (second.input || 0),
output: (first.output || 0) + (second.output || 0),
total: (first.total || 0) + (second.total || 0),
estimated: Boolean(first.estimated || second.estimated)
}
}
function reportFailure(
error: string,
errorStage: ScheduledReportExecutionStage,
errorCode?: string
): AgentGroupReportResult {
return {
success: false,
error,
errorStage,
...(errorCode ? { errorCode } : {})
} }
} }
+125
View File
@@ -0,0 +1,125 @@
/**
* Agent Hub 入站文本的**纯路由**:无 LLM、无副作用、可单测。
* 明确的产物 / 群成员分析 / 会话列表请求各走确定性 Action,其余一律进 Query Agent Runtime。
*
* **"总结" 不等于 Report**:只有同时提到"群"与明确产物才触发 Report Action。
* 查询类问题直接进 Query Agent,不做意图分类 —— 避免"先分类一次 LLM、再查询一次 LLM"。
*/
import type { AskWechatQueryResult } from '../../shared/query-agent'
export interface GroupReportIntent {
group: string
range: 'today' | 'yesterday' | '7days'
}
export interface GroupMemberChatIntent {
group: string
member: string
range: 'today' | 'yesterday' | '7days'
days: number
goal: string
}
export type InboundRoute =
| { kind: 'report_action'; intent: GroupReportIntent }
| { kind: 'group_member_action'; intent: GroupMemberChatIntent }
| { kind: 'recent_list'; limit: number }
| { kind: 'knowledge_query' }
/**
* Report Action 快捷匹配。
*
* 必须同时满足:提到"群" + 提到明确的**产物**(图片 / 长图 / 日报 / 报告)。
* 只提到"总结"不算 —— 那是 Query Agent 的 conversation_overview 场景。
*/
export function matchGroupReportIntent(text: string): GroupReportIntent | null {
const normalized = text.trim()
if (!normalized.includes('群') || !/(图片|长图|日报|报告)/.test(normalized)) return null
const range = /(7天|七天|一周)/.test(normalized)
? '7days'
: /(昨天|昨日)/.test(normalized)
? 'yesterday'
: 'today'
const group = normalized
.replace(
/请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g,
''
)
.replace(/[,。!??::]/g, '')
.trim()
.replace(/成$/, '')
.replace(/群$/, '')
.trim()
return group ? { group, range } : null
}
/** 群成员专用分析的快捷匹配(保留为 Action:它有自己的 goal 与输出形态)。 */
export function matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
const normalized = text.trim().replace(/[,。!??::]/g, '')
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
const patterns = [
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}`
),
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
)
]
for (const pattern of patterns) {
const match = normalized.match(pattern)
if (match?.[1]?.trim() && match[2]?.trim()) {
const range = /昨天|昨日/.test(match[3] || '')
? 'yesterday'
: /今天|今日/.test(match[3] || '')
? 'today'
: '7days'
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
return {
group: match[1].trim(),
member: match[2].trim(),
range,
days,
goal: normalized
}
}
}
return null
}
/**
* "最近有哪些会话"类请求。
*
* 这是**确定性能力**(列出会话),不是消息内容查询 —— Query Agent 无法表达,
* 因此保留为不经过模型的无 LLM 快捷路径。
*/
export function matchRecentChatIntent(text: string): number | null {
const normalized = text.replace(/\s+/g, '')
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
return Math.max(1, Math.min(20, limit))
}
export function resolveInboundRoute(text: string): InboundRoute {
const report = matchGroupReportIntent(text)
if (report) return { kind: 'report_action', intent: report }
const member = matchGroupMemberChatIntent(text)
if (member) return { kind: 'group_member_action', intent: member }
const recent = matchRecentChatIntent(text)
if (recent !== null) return { kind: 'recent_list', limit: recent }
return { kind: 'knowledge_query' }
}
/** 查询大脑不可用时的统一文案(不暴露 stack / provider raw response / 内部 id)。 */
export const QUERY_AGENT_UNAVAILABLE_TEXT = '当前 AI 查询服务暂时不可用,请稍后再试。'
/**
* 把 Query Agent 结果映射成**微信文字回复**。
* 只输出用户可读文本;Tool trace / temporalBasis / Evidence / 诊断字段一律不下发。
*/
export function queryAgentReplyText(result: AskWechatQueryResult): string {
if (result.status === 'answered') return result.answer
if (result.status === 'provider_unavailable' || result.status === 'error') return result.message
// Agent Hub 没有 Legacy runner,不会产生 legacy 结果;真出现时按服务不可用处理,不暴露内部结构。
return QUERY_AGENT_UNAVAILABLE_TEXT
}
+213 -388
View File
@@ -1,7 +1,7 @@
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { ChildProcess, execFile, spawn } from 'child_process' import { ChildProcess, execFile, spawn } from 'child_process'
import { randomBytes, timingSafeEqual } from 'crypto' import { randomBytes, timingSafeEqual } from 'crypto'
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { promisify } from 'util' import { promisify } from 'util'
@@ -15,6 +15,15 @@ import type {
import type { AppSettings } from './settings-store' import type { AppSettings } from './settings-store'
import { generateAgentGroupReport } from './agent-group-report-service' import { generateAgentGroupReport } from './agent-group-report-service'
import { AIProviderService } from './ai-provider-service' import { AIProviderService } from './ai-provider-service'
import { QueryAgentService } from './query-agent-service'
import { AskWechatService } from './ask-wechat-service'
import {
QUERY_AGENT_UNAVAILABLE_TEXT,
queryAgentReplyText,
resolveInboundRoute,
type GroupMemberChatIntent,
type GroupReportIntent
} from './agent-hub-routing'
import { isPackagedRuntime } from '../runtime-mode' import { isPackagedRuntime } from '../runtime-mode'
import { import {
getGroupSnapshot, getGroupSnapshot,
@@ -40,28 +49,17 @@ interface InboundMessage {
items?: Array<{ type?: number; text?: string }> items?: Array<{ type?: number; text?: string }>
} }
interface GroupReportIntent { interface AgentHubNotificationRecipient {
group: string accountId?: string
range: 'today' | 'yesterday' | '7days' userId: string
updatedAt: number
} }
interface ContactChatIntent { export interface AgentHubNotificationResult {
contact: string success: boolean
limit: number status: 'sent' | 'recipient_unavailable' | 'connector_offline' | 'token_expired' | 'send_failed'
summarize: boolean recipient?: string
} error?: string
interface GroupMemberChatIntent {
group: string
member: string
range: 'today' | 'yesterday' | '7days'
days: number
goal: string
}
interface NaturalLanguageResult {
command?: string
reply?: string
} }
const agentAIProvider = new AIProviderService() const agentAIProvider = new AIProviderService()
@@ -94,7 +92,7 @@ export function resolveWechatConnectorBinaryPath(
) )
} }
class AgentHubService { export class AgentHubService {
private hubServer: Server | null = null private hubServer: Server | null = null
private connectorChild: ChildProcess | null = null private connectorChild: ChildProcess | null = null
private loginChild: ChildProcess | null = null private loginChild: ChildProcess | null = null
@@ -103,6 +101,8 @@ class AgentHubService {
private logs: AgentHubLogEntry[] = [] private logs: AgentHubLogEntry[] = []
private nextLogId = 1 private nextLogId = 1
private readonly processedMessages = new Map<string, number>() private readonly processedMessages = new Map<string, number>()
private notificationRecipient: AgentHubNotificationRecipient | null = null
private notificationRecipientLoaded = false
private readonly inboundToken = private readonly inboundToken =
process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex') process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex')
private status: AgentHubStatus = { private status: AgentHubStatus = {
@@ -112,9 +112,28 @@ class AgentHubService {
updatedAt: Date.now() updatedAt: Date.now()
} }
/**
* 查询大脑。由主进程注入**同一个** QueryAgentRuntime 实例(桌面问问微信也用它),
* Agent Hub 只负责把微信问题送进去、把回答发回去。
*/
private queryAgent: AskWechatService | null = null
/**
* 注入生产 Query Agent Runtime(桌面与微信机器人共用同一实现,避免第二套 Query 语义)。
*/
setQueryAgentService(runtime: QueryAgentService): void {
this.queryAgent = new AskWechatService(runtime, {
entry: 'agent-hub',
// Agent Hub 没有 Legacy AI Search 通道:查询失败时给出明确文案,绝不误触 Report Action。
log: (record) =>
this.addLog('agent-hub', record.level === 'info' ? 'info' : record.level, record.message)
})
}
async start(settings: AppSettings): Promise<boolean> { async start(settings: AppSettings): Promise<boolean> {
void settings void settings
this.stopping = false this.stopping = false
this.loadNotificationRecipient()
const hubStarted = await this.startHub() const hubStarted = await this.startHub()
await this.initializeConnector() await this.initializeConnector()
return hubStarted return hubStarted
@@ -124,6 +143,53 @@ class AgentHubService {
return { ...this.status } return { ...this.status }
} }
/** The last user who sent an inbound message to this Agent Hub bot. */
getNotificationRecipient(): string | undefined {
this.loadNotificationRecipient()
return this.notificationRecipient?.userId
}
async sendNotification(input: {
to?: string
text: string
}): Promise<AgentHubNotificationResult> {
const to = String(input.to || this.getNotificationRecipient() || '').trim()
const text = String(input.text || '').trim()
if (!to || !text) {
return {
success: false,
status: 'recipient_unavailable',
error: 'Agent Hub 尚未记录可靠的通知接收者'
}
}
const accountId = this.notificationRecipient?.accountId || this.status.accountId
try {
const response = await this.postConnectorMessage({
accountId,
to,
text,
timeoutMs: 30_000
})
if (response.ok) return { success: true, status: 'sent', recipient: to }
const expired = /token|session|expired|unauthorized/i.test(response.body)
return {
success: false,
status: expired ? 'token_expired' : 'send_failed',
recipient: to,
error: expired
? 'Agent Hub 微信连接器登录凭证已失效'
: `Agent Hub 通知发送失败:${response.body || response.status}`
}
} catch (error) {
return {
success: false,
status: 'connector_offline',
recipient: to,
error: `Agent Hub 微信连接器不可用:${this.errorMessage(error)}`
}
}
}
getLogs(): AgentHubLogEntry[] { getLogs(): AgentHubLogEntry[] {
return [...this.logs] return [...this.logs]
} }
@@ -154,29 +220,24 @@ class AgentHubService {
} }
} }
try { try {
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, { const response = await this.postConnectorMessage({
method: 'POST', accountId: this.status.accountId,
headers: { 'Content-Type': 'application/json' }, to,
body: JSON.stringify({ text: text || undefined,
account_id: this.status.accountId, mediaUrl: mediaUrl || undefined,
to, timeoutMs: 30_000
text: text || undefined,
media_url: mediaUrl || undefined
}),
signal: AbortSignal.timeout(30_000)
}) })
const body = await response.text()
if (response.ok) { if (response.ok) {
this.addLog('system', 'info', 'API 页面发送测试成功') this.addLog('system', 'info', 'API 页面发送测试成功')
return { success: true, status: 'sent', message: '发送成功' } return { success: true, status: 'sent', message: '发送成功' }
} }
const expired = /token|session|expired|unauthorized/i.test(body) const expired = /token|session|expired|unauthorized/i.test(response.body)
return { return {
success: false, success: false,
status: expired ? 'token_expired' : 'send_failed', status: expired ? 'token_expired' : 'send_failed',
message: expired message: expired
? '微信登录凭证已失效,请重新扫码登录' ? '微信登录凭证已失效,请重新扫码登录'
: `发送失败:${body || response.status}` : `发送失败:${response.body || response.status}`
} }
} catch (error) { } catch (error) {
return { return {
@@ -326,6 +387,7 @@ class AgentHubService {
} }
const from = String(inbound.from_user_id || '').trim() const from = String(inbound.from_user_id || '').trim()
if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' }) if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' })
this.rememberNotificationRecipient(inbound.account_id, from)
const messageId = String(inbound.message_id || '') const messageId = String(inbound.message_id || '')
this.cleanProcessedMessages() this.cleanProcessedMessages()
@@ -338,301 +400,82 @@ class AgentHubService {
.join(' ') .join(' ')
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`) this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
const reportIntent = this.matchGroupReportIntent(text) // 三路边界:明确产物 → Report / 成员分析 Action;会话列表 → 确定性能力;其余 → Query Agent。
// 注意:这里**不再**先跑意图分类 LLM,查询类问题直接进入 Query Agent(避免双重 LLM 语义系统)。
const route = resolveInboundRoute(text)
if (reportIntent) { if (route.kind === 'report_action') {
if (messageId) this.processedMessages.set(messageId, Date.now()) if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog( this.addLog(
'agent-hub', 'agent-hub',
'info', 'info',
`匹配群聊总结:${reportIntent.group}(${reportIntent.range})` `匹配群聊总结:${route.intent.group}(${route.intent.range})`
) )
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => { await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => {
this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`) this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`)
}) })
void this.generateAndSendReport(inbound, reportIntent) void this.generateAndSendReport(inbound, route.intent)
return this.sendHubJson(response, 202, { status: 'generating' }) return this.sendHubJson(response, 202, { status: 'generating' })
} }
const groupMemberIntent = this.matchGroupMemberChatIntent(text) if (route.kind === 'group_member_action') {
if (groupMemberIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now()) if (messageId) this.processedMessages.set(messageId, Date.now())
void this.summarizeGroupMemberChat(inbound, groupMemberIntent) void this.summarizeGroupMemberChat(inbound, route.intent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' }) return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
} }
const contactChatIntent = this.matchContactChatIntent(text) if (route.kind === 'recent_list') {
if (contactChatIntent) { if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
if (messageId) this.processedMessages.set(messageId, Date.now()) const items = listRecentChat(route.limit)
if (contactChatIntent.summarize) { const lines = items.map((item, index) => {
void this.summarizeContactChat(inbound, contactChatIntent) const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' }) return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
})
const reply = lines.length
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
: '暂时没有找到最近会话。'
try {
await this.sendConnector(inbound, reply)
} catch (error) {
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
} }
await this.replyContactChat(inbound, contactChatIntent) if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
return this.sendHubJson(response, 200, { status: 'ok' }) return this.sendHubJson(response, 200, { status: 'ok' })
} }
const recentChatLimit = this.matchRecentChatIntent(text) if (!text.trim()) {
if (recentChatLimit === null && text.trim()) { this.addLog('agent-hub', 'info', '消息已忽略:内容为空')
if (messageId) this.processedMessages.set(messageId, Date.now()) return this.sendHubJson(response, 202, { status: 'ignored', reason: 'empty text' })
void this.handleNaturalLanguage(inbound, text)
return this.sendHubJson(response, 202, {
status: 'processing',
mode: 'natural-language'
})
}
if (recentChatLimit === null) {
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
}
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
const items = listRecentChat(recentChatLimit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
})
const reply = lines.length
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
: '暂时没有找到最近会话。'
try {
await this.sendConnector(inbound, reply)
} catch (error) {
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
} }
if (messageId) this.processedMessages.set(messageId, Date.now()) if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`) void this.handleKnowledgeQuery(inbound, text)
this.sendHubJson(response, 200, { status: 'ok' }) return this.sendHubJson(response, 202, { status: 'processing', mode: 'query-agent' })
} }
private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> { /**
try { * 查询类问题("微信里发生了什么"、普通闲聊)统一走 Query Agent Runtime。
const result = await this.resolveNaturalLanguage(text) * 失败时不回退 Report Action,只给用户明确文案。
if (result.reply) { */
await this.sendConnector(inbound, this.formatAIReply(result.reply)) private async handleKnowledgeQuery(inbound: InboundMessage, text: string): Promise<void> {
this.addLog('agent-hub', 'info', '自然语言回复已发送') const service = this.queryAgent
return if (!service) {
} this.addLog('agent-hub', 'error', '查询大脑尚未初始化')
if (!result.command) { await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined)
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
return
}
this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`)
const reportIntent = this.matchGroupReportIntent(result.command)
if (reportIntent) {
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined)
await this.generateAndSendReport(inbound, reportIntent)
return
}
const groupMemberIntent = this.matchGroupMemberChatIntent(result.command)
if (groupMemberIntent) {
await this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return
}
const contactIntent = this.matchContactChatIntent(result.command)
if (contactIntent) {
if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent)
else await this.replyContactChat(inbound, contactIntent)
return
}
const recentLimit = this.matchRecentChatIntent(result.command)
if (recentLimit !== null) {
await this.replyRecentChats(inbound, recentLimit)
return
}
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
} catch (error) {
this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return return
} }
const items = listRecentChat(limit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
})
await this.sendConnector(
inbound,
lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。'
)
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
}
private async resolveNaturalLanguage(text: string): Promise<NaturalLanguageResult> {
const result = await agentAIProvider.chat([
{
role: 'system',
content: `你是 TraceMemo 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
支持的工具:
1. recent:查看最近会话,参数 limit 为 1-20。
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
3. report:生成某个群的群聊总结图片,参数 group 和 range(today、yesterday、7days)。
4. group_member:分析某个群里某位成员的发言,参数 group、member、range(today、yesterday、7days)、days(1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。
5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。
输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""}
不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。`
},
{ role: 'user', content: text.slice(0, 1000) }
])
if (!result.success || !result.data) {
this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`)
return {}
}
try { try {
const json = result.data.match(/\{[\s\S]*\}/)?.[0] const conversationKey = `${String(inbound.account_id || '')}::${String(inbound.from_user_id || '')}`
if (!json) return {} const result = await service.ask(
const parsed = JSON.parse(json) as Record<string, unknown> { requestId: `agent-hub-${Date.now()}-${this.nextLogId}`, text },
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5)) conversationKey
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` } )
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) { await this.sendConnector(inbound, this.formatAIReply(queryAgentReplyText(result)))
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` } this.addLog('agent-hub', 'info', `查询回答已发送(${result.status})`)
}
if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) {
const range =
parsed['range'] === '7days'
? '最近7天'
: parsed['range'] === 'yesterday'
? '昨天'
: '今天'
return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` }
}
if (
parsed['type'] === 'group_member' &&
String(parsed['group'] || '').trim() &&
String(parsed['member'] || '').trim()
) {
const range =
parsed['range'] === 'yesterday'
? '昨天'
: parsed['range'] === 'today'
? '今天'
: '最近7天'
const group = String(parsed['group'] || '')
.trim()
.replace(/(?:群聊|群)+$/g, '')
const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7))
const goal = String(parsed['goal'] || '总结发言').trim()
return {
command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}天` : range}说了什么,${goal}`
}
}
if (parsed['type'] === 'chat') {
const reply = String(parsed['reply'] || '').trim()
return reply ? { reply: reply.slice(0, 1500) } : {}
}
} catch (error) { } catch (error) {
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`) this.addLog('agent-hub', 'error', `查询处理失败:${this.errorMessage(error)}`)
} await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined)
return {}
}
private async replyContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
if (!contact || contact.type !== 'user') {
this.addLog('agent-hub', 'info', `没有匹配到联系人:${intent.contact}`)
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
return
}
this.addLog(
'agent-hub',
'info',
`匹配联系人聊天查询:${contact.m_nsNickName}(最近 ${intent.limit} 条)`
)
const messages = listMessages(contact.md5, undefined, undefined, { limit: intent.limit })
const recent = messages.slice(-intent.limit)
const lines = recent.map((message) => {
const speaker = message.isSender ? '我' : contact.m_nsNickName
const content = this.describeChatMessage(message.content, message.type)
return `${speaker}:${content}`
})
const reply = lines.length
? `我和${contact.m_nsNickName}最近聊了这些:\n${lines.join('\n')}`
: `暂时没有找到和${contact.m_nsNickName}的聊天记录。`
await this.sendConnector(inbound, reply)
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
}
private async summarizeContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
if (!contact || contact.type !== 'user') {
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
return
}
await this.sendConnector(
inbound,
`收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…`
)
const endTime = Math.floor(Date.now() / 1000)
const startTime = endTime - 7 * 24 * 60 * 60
const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300)
if (!messages.length) {
await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`)
return
}
const transcript = messages
.map((message) => {
const speaker = message.isSender ? '我' : contact.m_nsNickName
return `[${message.datetime}] ${speaker}:${this.describeChatMessage(message.content, message.type)}`
})
.join('\n')
const summary = await agentAIProvider.chat([
{
role: 'system',
content:
'你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。'
},
{
role: 'user',
content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}`
}
])
if (!summary.success || !summary.data?.trim()) {
throw new Error(summary.error || 'AI 未返回总结')
}
await this.sendConnector(
inbound,
this.formatAIReply(
`和${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
)
)
this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`)
} catch (error) {
this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch(
() => undefined
)
} }
} }
@@ -791,77 +634,35 @@ class AgentHubService {
text?: string, text?: string,
mediaUrl?: string mediaUrl?: string
): Promise<void> { ): Promise<void> {
const response = await this.postConnectorMessage({
accountId: inbound.account_id || this.status.accountId,
to: String(inbound.from_user_id || '').trim(),
text,
mediaUrl,
timeoutMs: mediaUrl ? 60_000 : 30_000
})
if (!response.ok) throw new Error(response.body || `HTTP ${response.status}`)
}
private async postConnectorMessage(input: {
accountId?: string
to: string
text?: string
mediaUrl?: string
timeoutMs: number
}): Promise<{ ok: boolean; status: number; body: string }> {
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, { const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
account_id: inbound.account_id, account_id: input.accountId,
to: inbound.from_user_id, to: input.to,
text, text: input.text,
media_url: mediaUrl media_url: input.mediaUrl
}), }),
signal: AbortSignal.timeout(mediaUrl ? 60_000 : 30_000) signal: AbortSignal.timeout(input.timeoutMs)
}) })
if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`) return { ok: response.ok, status: response.status, body: await response.text() }
}
private matchRecentChatIntent(text: string): number | null {
const normalized = text.replace(/\s+/g, '')
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
return Math.max(1, Math.min(20, limit))
}
private matchContactChatIntent(text: string): ContactChatIntent | null {
const normalized = text.replace(/\s+/g, '').replace(/[,。!??::]/g, '')
if (!normalized.includes('最近') || !/(聊|消息|会话)/.test(normalized)) return null
const patterns = [
/(?:看一下|看看|查一下|查询)?我和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?(?:我)?最近(?:\d{1,2}条)?和(.+?)(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/
]
const contact = patterns
.map((pattern) => normalized.match(pattern)?.[1]?.trim())
.find((value): value is string => Boolean(value))
if (!contact) return null
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized)
return { contact, limit: Math.max(1, Math.min(20, limit)), summarize }
}
private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
const normalized = text.trim().replace(/[,。!??::]/g, '')
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
const patterns = [
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}`
),
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
)
]
for (const pattern of patterns) {
const match = normalized.match(pattern)
if (match?.[1]?.trim() && match[2]?.trim()) {
const range = /昨天|昨日/.test(match[3] || '')
? 'yesterday'
: /今天|今日/.test(match[3] || '')
? 'today'
: '7days'
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
return {
group: match[1].trim(),
member: match[2].trim(),
range,
days,
goal: normalized
}
}
}
return null
} }
private resolveGroup(query: string): ReturnType<typeof resolveMd5> { private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
@@ -885,27 +686,6 @@ class AgentHubService {
) )
} }
private matchGroupReportIntent(text: string): GroupReportIntent | null {
const normalized = text.trim()
if (!normalized.includes('群') || !/(总结|日报|报告)/.test(normalized)) return null
const range = /(7天|七天|一周)/.test(normalized)
? '7days'
: /(昨天|昨日)/.test(normalized)
? 'yesterday'
: 'today'
const group = normalized
.replace(
/请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g,
''
)
.replace(/[,。!??::]/g, '')
.trim()
.replace(/成$/, '')
.replace(/群$/, '')
.trim()
return group ? { group, range } : null
}
private authorized(header: string | undefined): boolean { private authorized(header: string | undefined): boolean {
if (!header?.startsWith('Bearer ')) return false if (!header?.startsWith('Bearer ')) return false
const expected = Buffer.from(this.inboundToken) const expected = Buffer.from(this.inboundToken)
@@ -965,6 +745,51 @@ class AgentHubService {
} }
} }
private loadNotificationRecipient(): void {
if (this.notificationRecipientLoaded) return
this.notificationRecipientLoaded = true
try {
const stored = JSON.parse(readFileSync(this.notificationRecipientPath(), 'utf8')) as {
accountId?: unknown
userId?: unknown
updatedAt?: unknown
}
const userId = String(stored.userId || '').trim()
if (userId) {
this.notificationRecipient = {
userId,
accountId: String(stored.accountId || '').trim() || undefined,
updatedAt: Number(stored.updatedAt) || Date.now()
}
}
} catch {
this.notificationRecipient = null
}
}
private rememberNotificationRecipient(accountId: string | undefined, userId: string): void {
const normalizedUserId = String(userId || '').trim()
if (!normalizedUserId) return
const recipient: AgentHubNotificationRecipient = {
userId: normalizedUserId,
accountId: String(accountId || this.status.accountId || '').trim() || undefined,
updatedAt: Date.now()
}
this.notificationRecipient = recipient
this.notificationRecipientLoaded = true
try {
const filePath = this.notificationRecipientPath()
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify(recipient, null, 2), 'utf8')
} catch (error) {
this.addLog('agent-hub', 'warn', `通知接收者保存失败:${this.errorMessage(error)}`)
}
}
private notificationRecipientPath(): string {
return join(app.getPath('userData'), 'agent-hub', 'notification-recipient.json')
}
private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> { private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> {
const executable = resolveWechatConnectorBinaryPath() const executable = resolveWechatConnectorBinaryPath()
if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`) if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`)
+721 -79
View File
@@ -1,4 +1,5 @@
import { app } from 'electron' import { app } from 'electron'
import { randomUUID } from 'crypto'
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import type { import type {
@@ -24,23 +25,91 @@ interface AIProviderMetadataFile {
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string } type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
type AIMessage = { role: string; content: string | AIMessagePart[] } type AIMessage = { role: string; content: string | AIMessagePart[] }
type AIChatDeltaHandler = (delta: string) => void
export interface AIChatToolCall {
id: string
name: string
arguments: string
}
export interface AIChatToolDefinition {
type: 'function'
function: { name: string; description: string; parameters: Record<string, unknown> }
}
type AIRequestResult = { type AIRequestResult = {
data: string data: string
finishReason?: string finishReason?: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean } usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
} }
interface OpenAIResponsePayload { interface OpenAIResponsePayload {
error?: { message?: string } error?: { message?: string; code?: string | number; type?: string }
choices?: Array<{ message?: { content?: string }; finish_reason?: string }> choices?: Array<{
message?: {
content?: string | Array<{ type?: string; text?: string }> | null
reasoning_content?: string
tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: string } }>
}
finish_reason?: string
}>
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
} }
interface OpenAIStreamPayload {
error?: { message?: string; code?: string | number; type?: string }
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
}
interface OpenAIResponsesPayload {
error?: { message?: string; code?: string | number; type?: string }
incomplete_details?: { reason?: string }
status?: string
output_text?: string
output?: Array<{
type?: string
content?: Array<{ type?: string; text?: string }>
}>
usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number }
}
interface OpenAIResponsesStreamPayload {
type?: string
delta?: string
message?: string
error?: { message?: string; code?: string | number; type?: string }
response?: OpenAIResponsesPayload
}
interface AnthropicResponsePayload { interface AnthropicResponsePayload {
error?: { message?: string } error?: { message?: string; type?: string }
content?: Array<{ type?: string; text?: string }> content?: Array<{ type?: string; text?: string }>
stop_reason?: string stop_reason?: string
usage?: { input_tokens?: number; output_tokens?: number } usage?: { input_tokens?: number; output_tokens?: number }
} }
export class AIProviderRequestError extends Error {
readonly status?: number
readonly code?: string
readonly type?: string
readonly responseBody?: unknown
/** 诊断字段:上游响应 content-type(例如 502 返回的 text/html) */
readonly contentType?: string
/** 诊断字段:本次请求从发出到响应处理结束的耗时 */
readonly elapsedMs?: number
/** 诊断字段:上游返回网页而不是 JSON */
readonly htmlInsteadOfJson?: boolean
constructor(
message: string,
details: { status?: number; code?: unknown; type?: unknown; responseBody?: unknown; contentType?: string; elapsedMs?: number; htmlInsteadOfJson?: boolean } = {}
) {
super(message)
this.name = 'AIProviderRequestError'
this.status = details.status
this.code = typeof details.code === 'string' ? details.code : undefined
this.type = typeof details.type === 'string' ? details.type : undefined
this.responseBody = details.responseBody
this.contentType = details.contentType
this.elapsedMs = details.elapsedMs
this.htmlInsteadOfJson = details.htmlInsteadOfJson
}
}
export class AIProviderService { export class AIProviderService {
constructor(private readonly keyStore = new AIProviderKeyStore()) {} constructor(private readonly keyStore = new AIProviderKeyStore()) {}
@@ -77,6 +146,19 @@ export class AIProviderService {
} }
} }
/**
* 诊断用:当前默认 Provider 的 endpoint host。
* 只返回 hostname(不含路径与 query),避免把凭据或敏感 query 带进日志/终端。
*/
getRuntimeEndpointHost(): string | undefined {
try {
const resolved = this.resolveProvider(undefined)
return endpointHost(resolved.provider.baseUrl)
} catch {
return undefined
}
}
getVisionRuntimeConfig(): AIVisionRuntimeConfig { getVisionRuntimeConfig(): AIVisionRuntimeConfig {
const result = this.list() const result = this.list()
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId) const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
@@ -234,18 +316,78 @@ export class AIProviderService {
async chat( async chat(
messages: Array<{ role: string; content: string }>, messages: Array<{ role: string; content: string }>,
options?: AIChatRequestOptions, options?: AIChatRequestOptions,
signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<{
success: boolean
data?: string
finishReason?: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
error?: string
errorCode?: string
errorStatus?: number
errorType?: string
}> {
try {
return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) }
} catch (error) {
if (signal?.aborted) throw error
return { success: false, error: safeAIError(error), ...aiProviderErrorDetails(error) }
}
}
async chatWithTools(
messages: Array<Record<string, unknown>>,
tools: AIChatToolDefinition[],
options?: AIChatRequestOptions,
signal?: AbortSignal signal?: AbortSignal
): Promise<{ ): Promise<{
success: boolean success: boolean
data?: string data?: string
toolCalls?: AIChatToolCall[]
usage?: { input?: number; output?: number; total?: number; estimated?: boolean } usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
error?: string error?: string
errorStatus?: number
errorCode?: string
errorType?: string
errorContentType?: string
elapsedMs?: number
timedOut?: boolean
htmlInsteadOfJson?: boolean
}> { }> {
try { try {
return { success: true, ...(await this.request(messages, options, false, signal)) } if (options?.apiKey) throw new Error('Tool Calling 不支持 legacy provider 配置')
const resolved = this.resolveProvider(options)
if (resolved.provider.type !== 'openai-compatible' || resolved.provider.advanced.apiProtocol === 'responses') {
throw new Error('当前 Provider 协议暂不支持 Tool Calling POC')
}
const result = await requestOpenAICompatibleWithTools(
resolved.provider,
resolved.key,
resolved.model,
messages,
tools,
signal
)
return { success: true, ...result }
} catch (error) { } catch (error) {
if (signal?.aborted) throw error if (signal?.aborted) throw error
return { success: false, error: safeAIError(error) } const timedOut = error instanceof DOMException && error.name === 'TimeoutError'
return {
success: false,
error: safeAIError(error),
timedOut,
htmlInsteadOfJson: error instanceof AIProviderRequestError ? Boolean(error.htmlInsteadOfJson) : false,
...(error instanceof AIProviderRequestError
? {
...(error.status !== undefined ? { errorStatus: error.status } : {}),
...(error.code ? { errorCode: error.code } : {}),
...(error.type ? { errorType: error.type } : {}),
...(error.contentType ? { errorContentType: error.contentType } : {}),
...(error.elapsedMs !== undefined ? { elapsedMs: error.elapsedMs } : {})
}
: {})
}
} }
} }
@@ -323,12 +465,14 @@ export class AIProviderService {
messages: AIMessage[], messages: AIMessage[],
options?: AIChatRequestOptions, options?: AIChatRequestOptions,
testing = false, testing = false,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<{ ): Promise<{
data: string data: string
finishReason?: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean } usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
}> { }> {
if (options?.apiKey) return this.requestLegacy(messages, options, signal) if (options?.apiKey) return this.requestLegacy(messages, options, signal, onDelta)
const resolved = this.resolveProvider(options) const resolved = this.resolveProvider(options)
const provider = options?.timeoutMs const provider = options?.timeoutMs
? { ? {
@@ -336,7 +480,16 @@ export class AIProviderService {
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs } advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
} }
: resolved.provider : resolved.provider
return requestProvider(provider, resolved.key, resolved.model, messages, testing, signal) return requestProvider(
provider,
resolved.key,
resolved.model,
messages,
testing,
signal,
onDelta,
options?.sessionId
)
} }
private resolveProvider(options?: { providerId?: string; modelId?: string }): { private resolveProvider(options?: { providerId?: string; modelId?: string }): {
@@ -359,16 +512,19 @@ export class AIProviderService {
private async requestLegacy( private async requestLegacy(
messages: AIMessage[], messages: AIMessage[],
options: AIChatRequestOptions, options: AIChatRequestOptions,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> { ): Promise<AIRequestResult> {
const provider = deepSeekProvider(options.baseURL, options.model) const provider = deepSeekProvider(options.baseURL, options.model)
return requestOpenAICompatible( return requestProvider(
provider, provider,
options.apiKey || '', options.apiKey || '',
options.model || provider.defaultModel, options.model || provider.defaultModel,
messages, messages,
false, false,
signal signal,
onDelta,
options?.sessionId
) )
} }
@@ -489,6 +645,15 @@ function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
} }
} }
/** 诊断用:只取 endpoint 的 hostname,不保留路径与 query。 */
function endpointHost(baseUrl: string): string | undefined {
try {
return new URL(baseUrl).hostname || undefined
} catch {
return undefined
}
}
function stripRuntimeFields( function stripRuntimeFields(
provider: AIProviderSummary provider: AIProviderSummary
): Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> { ): Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> {
@@ -542,6 +707,11 @@ function validateProvider(provider: AIProviderConfig): string | undefined {
return '默认模型不在模型列表中' return '默认模型不在模型列表中'
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim()) if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
return '请填写自定义认证字段' return '请填写自定义认证字段'
if (
provider.advanced.apiProtocol &&
!['chat-completions', 'responses'].includes(provider.advanced.apiProtocol)
)
return 'OpenAI API 接口配置不正确'
return undefined return undefined
} }
@@ -557,17 +727,39 @@ function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<strin
return headers return headers
} }
function hasHeader(headers: Record<string, string>, name: string): boolean {
const normalizedName = name.toLowerCase()
return Object.keys(headers).some((header) => header.toLowerCase() === normalizedName)
}
function requestProvider( function requestProvider(
provider: AIProviderSummary, provider: AIProviderSummary,
apiKey: string, apiKey: string,
model: string, model: string,
messages: AIMessage[], messages: AIMessage[],
testing = false, testing = false,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler,
sessionId?: string
): Promise<AIRequestResult> { ): Promise<AIRequestResult> {
return provider.type === 'anthropic-messages' // Match the endpoint, not the editable provider name or model name.
? requestAnthropic(provider, apiKey, model, messages, testing, signal) const url = new URL(provider.baseUrl)
: requestOpenAICompatible(provider, apiKey, model, messages, testing, signal) if (url.hostname === 'opencode.ai' && /^\/zen\/go(?:\/|$)/.test(url.pathname)) {
const extraHeaders = Object.fromEntries(
Object.entries(provider.advanced.extraHeaders).filter(
([name]) => name.toLowerCase() !== 'x-opencode-session'
)
)
extraHeaders['x-opencode-session'] = sessionId?.trim() || randomUUID()
if (!hasHeader(extraHeaders, 'user-agent')) extraHeaders['user-agent'] = 'TraceMemo'
provider = { ...provider, advanced: { ...provider.advanced, extraHeaders } }
}
if (provider.type === 'anthropic-messages') {
return requestAnthropic(provider, apiKey, model, messages, testing, signal)
}
return provider.advanced.apiProtocol === 'responses'
? requestOpenAIResponses(provider, apiKey, model, messages, testing, signal, onDelta)
: requestOpenAICompatible(provider, apiKey, model, messages, testing, signal, onDelta)
} }
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> { function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
@@ -584,6 +776,58 @@ function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content:
})) }))
} }
function openAIMessageText(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.map((part) => {
if (!part || typeof part !== 'object') return ''
const text = (part as { text?: unknown }).text
return typeof text === 'string' ? text : ''
})
.join('')
}
function toOpenAIResponsesRequest(messages: AIMessage[]): {
instructions?: string
input: Array<{ role: string; content: unknown[] }>
} {
const instructions = messages
.filter((message) => message.role === 'system')
.map((message) =>
typeof message.content === 'string'
? message.content
: message.content
.filter((part) => part.type === 'text')
.map((part) => (part.type === 'text' ? part.text : ''))
.join('\n')
)
.filter(Boolean)
.join('\n\n')
const input = messages
.filter((message) => message.role !== 'system')
.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content:
typeof message.content === 'string'
? [
{
type: message.role === 'assistant' ? 'output_text' : 'input_text',
text: message.content
}
]
: message.content.map((part) =>
part.type === 'text'
? {
type: message.role === 'assistant' ? 'output_text' : 'input_text',
text: part.text
}
: { type: 'input_image', image_url: part.dataUrl }
)
}))
return { instructions: instructions || undefined, input }
}
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> { function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages return messages
.filter((message) => message.role !== 'system') .filter((message) => message.role !== 'system')
@@ -603,24 +847,27 @@ function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; conte
})) }))
} }
function modelMaxTokens(provider: AIProviderSummary, model: string): number {
return (
provider.models.find((item) => item.id === model)?.maxTokens ||
provider.advanced.maxTokens ||
4096
)
}
async function requestOpenAICompatible( async function requestOpenAICompatible(
provider: AIProviderSummary, provider: AIProviderSummary,
apiKey: string, apiKey: string,
model: string, model: string,
messages: AIMessage[], messages: AIMessage[],
testing = false, testing = false,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> { ): Promise<AIRequestResult> {
const endpoint = provider.baseUrl.endsWith('/chat/completions') const endpoint = provider.baseUrl.endsWith('/chat/completions')
? provider.baseUrl ? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions` : `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
let modelMaxTokens = 0 return fetchWithTimeout(
for (const m of provider.models) {
if (m.id === model && m.maxTokens) {
modelMaxTokens = m.maxTokens
}
}
const response = await fetchWithTimeout(
endpoint, endpoint,
{ {
method: 'POST', method: 'POST',
@@ -629,30 +876,171 @@ async function requestOpenAICompatible(
model, model,
messages: toOpenAIMessages(messages), messages: toOpenAIMessages(messages),
temperature: provider.advanced.temperature, temperature: provider.advanced.temperature,
max_tokens: testing max_tokens: testing ? 8 : modelMaxTokens(provider, model),
? 8 ...(provider.advanced.thinking === 'disabled' ? { thinking: { type: 'disabled' } } : {}),
: modelMaxTokens > 0 ...(provider.advanced.stream ? { stream: true } : {})
? modelMaxTokens
: provider.advanced.maxTokens || 4096
}) })
}, },
provider.advanced.timeoutMs, provider.advanced.timeoutMs,
signal signal,
async (response) => {
if (!response.ok) {
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
throw new AIProviderRequestError(
payload.error?.message || `AI 请求失败 (${response.status})`,
{
status: response.status,
code: payload.error?.code,
type: payload.error?.type,
responseBody: payload.error
}
)
}
if (
provider.advanced.stream &&
!response.headers.get('content-type')?.toLowerCase().includes('application/json')
) {
return parseOpenAIStream(response, onDelta)
}
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
return {
data: openAIMessageText(payload.choices?.[0]?.message?.content),
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
usage: toOpenAIUsage(payload.usage)
}
}
) )
const payload = await parseJsonResponse<OpenAIResponsePayload>(response) }
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
return { async function requestOpenAIResponses(
data: String(payload.choices?.[0]?.message?.content || ''), provider: AIProviderSummary,
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'), apiKey: string,
usage: payload.usage model: string,
? { messages: AIMessage[],
input: payload.usage.prompt_tokens, testing = false,
output: payload.usage.completion_tokens, signal?: AbortSignal,
total: payload.usage.total_tokens, onDelta?: AIChatDeltaHandler
estimated: false ): Promise<AIRequestResult> {
} const normalizedBaseUrl = provider.baseUrl.replace(/\/+$/, '')
: undefined const endpoint = normalizedBaseUrl.endsWith('/responses')
? normalizedBaseUrl
: normalizedBaseUrl.endsWith('/chat/completions')
? `${normalizedBaseUrl.slice(0, -'/chat/completions'.length)}/responses`
: `${normalizedBaseUrl}/responses`
const request = toOpenAIResponsesRequest(messages)
const headers = buildHeaders(provider, apiKey)
if (provider.advanced.stream && !hasHeader(headers, 'accept')) {
headers.accept = 'text/event-stream'
} }
return fetchWithTimeout(
endpoint,
{
method: 'POST',
headers,
body: JSON.stringify({
model,
instructions: request.instructions,
input: request.input,
temperature: provider.advanced.temperature,
max_output_tokens: testing ? 128 : modelMaxTokens(provider, model),
store: false,
...(provider.advanced.stream ? { stream: true } : {})
})
},
provider.advanced.timeoutMs,
signal,
async (response) => {
if (!response.ok) {
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
throw new AIProviderRequestError(
payload.error?.message || `AI 请求失败 (${response.status})`,
{
status: response.status,
code: payload.error?.code,
type: payload.error?.type,
responseBody: payload.error
}
)
}
if (provider.advanced.stream) return parseOpenAIResponsesStream(response, onDelta)
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
if (payload.error?.message) throw new Error(payload.error.message)
return {
data: extractOpenAIResponsesText(payload),
finishReason: openAIResponsesFinishReason(payload) || 'unknown',
usage: toOpenAIResponsesUsage(payload.usage)
}
}
)
}
async function requestOpenAICompatibleWithTools(
provider: AIProviderSummary,
apiKey: string,
model: string,
messages: Array<Record<string, unknown>>,
tools: AIChatToolDefinition[],
signal?: AbortSignal
): Promise<{ data: string; toolCalls: AIChatToolCall[]; usage?: AIRequestResult['usage']; elapsedMs?: number }> {
const endpoint = provider.baseUrl.endsWith('/chat/completions')
? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
return fetchWithTimeout(
endpoint,
{
method: 'POST',
headers: buildHeaders(provider, apiKey),
body: JSON.stringify({
model,
messages,
...(tools.length ? { tools, tool_choice: 'auto' } : { tool_choice: 'none' }),
temperature: provider.advanced.temperature,
max_tokens: modelMaxTokens(provider, model),
...(provider.advanced.thinking === 'disabled' ? { thinking: { type: 'disabled' } } : {})
})
},
provider.advanced.timeoutMs,
signal,
async (response, timing) => {
// 先尝试解析 body:非 2xx 时上游可能返回 JSON 错误体,也可能是 HTML 错误页。
// 无论哪种,都要把 status / content-type / elapsed 带进错误,避免丢失上下文。
let payload: OpenAIResponsePayload | undefined
let parseError: unknown
try {
payload = await parseJsonResponse<OpenAIResponsePayload>(response)
} catch (error) {
parseError = error
}
if (!response.ok) {
throw new AIProviderRequestError(
payload?.error?.message || (parseError ? safeAIError(parseError) : `AI 请求失败 (${response.status})`),
{
status: timing.status,
contentType: timing.contentType,
elapsedMs: timing.elapsedMs,
htmlInsteadOfJson: isHtmlParseError(parseError),
code: payload?.error?.code,
type: payload?.error?.type,
responseBody: payload?.error
}
)
}
// 2xx 但 body 无法解析:抛出原始解析错误(含清晰的中文提示)。
if (!payload) throw parseError ?? new Error('AI 响应格式异常')
if (parseError) throw parseError
const message = payload.choices?.[0]?.message
return {
data: openAIMessageText(message?.content),
toolCalls: (message?.tool_calls || []).flatMap((call, index) => {
const name = call.function?.name
if (!name) return []
return [{ id: call.id || `tool-call-${index + 1}`, name, arguments: call.function?.arguments || '{}' }]
}),
usage: toOpenAIUsage(payload.usage),
elapsedMs: timing.elapsedMs
}
}
)
} }
async function requestAnthropic( async function requestAnthropic(
@@ -680,13 +1068,7 @@ async function requestAnthropic(
const endpoint = provider.baseUrl.endsWith('/messages') const endpoint = provider.baseUrl.endsWith('/messages')
? provider.baseUrl ? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/messages` : `${provider.baseUrl.replace(/\/+$/, '')}/messages`
let modelMaxTokens = 0 return fetchWithTimeout(
for (const m of provider.models) {
if (m.id === model && m.maxTokens) {
modelMaxTokens = m.maxTokens
}
}
const response = await fetchWithTimeout(
endpoint, endpoint,
{ {
method: 'POST', method: 'POST',
@@ -696,44 +1078,58 @@ async function requestAnthropic(
system: system || undefined, system: system || undefined,
messages: anthropicMessages, messages: anthropicMessages,
temperature: provider.advanced.temperature, temperature: provider.advanced.temperature,
max_tokens: testing max_tokens: testing ? 8 : modelMaxTokens(provider, model)
? 8
: modelMaxTokens > 0
? modelMaxTokens
: provider.advanced.maxTokens || 4096
}) })
}, },
provider.advanced.timeoutMs, provider.advanced.timeoutMs,
signal signal,
async (response) => {
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
if (!response.ok)
throw new AIProviderRequestError(
payload.error?.message || `Anthropic 请求失败 (${response.status})`,
{
status: response.status,
type: payload.error?.type,
responseBody: payload.error
}
)
return {
data: Array.isArray(payload.content)
? payload.content
.filter((item) => item.type === 'text')
.map((item) => item.text || '')
.join('\n')
: '',
finishReason: String(payload.stop_reason || 'unknown'),
usage: payload.usage
? {
input: payload.usage.input_tokens,
output: payload.usage.output_tokens,
total:
Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
estimated: false
}
: undefined
}
}
) )
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
if (!response.ok)
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
return {
data: Array.isArray(payload.content)
? payload.content
.filter((item) => item.type === 'text')
.map((item) => item.text || '')
.join('\n')
: '',
finishReason: String(payload.stop_reason || 'unknown'),
usage: payload.usage
? {
input: payload.usage.input_tokens,
output: payload.usage.output_tokens,
total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
estimated: false
}
: undefined
}
} }
async function fetchWithTimeout( /** 请求级诊断:不包含任何凭据,仅用于区分「上游慢 / 上游错误 / 本地解析」。 */
interface AIRequestTiming {
elapsedMs: number
status: number
contentType: string
}
async function fetchWithTimeout<T>(
url: string, url: string,
init: RequestInit, init: RequestInit,
timeoutMs: number, timeoutMs: number,
signal?: AbortSignal signal: AbortSignal | undefined,
): Promise<Response> { consume: (response: Response, timing: AIRequestTiming) => Promise<T>
): Promise<T> {
const controller = new AbortController() const controller = new AbortController()
let timedOut = false let timedOut = false
const abortFromCaller = (): void => const abortFromCaller = (): void =>
@@ -747,8 +1143,16 @@ async function fetchWithTimeout(
}, },
Math.max(1_000, timeoutMs || 120_000) Math.max(1_000, timeoutMs || 120_000)
) )
const startedAt = Date.now()
let responseReceivedAt = startedAt
try { try {
return await fetch(url, { ...init, signal: controller.signal }) const response = await fetch(url, { ...init, signal: controller.signal })
responseReceivedAt = Date.now()
return await consume(response, {
elapsedMs: responseReceivedAt - startedAt,
status: response.status,
contentType: response.headers.get('content-type') || ''
})
} catch (error) { } catch (error) {
if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError') if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError')
if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError') if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError')
@@ -759,6 +1163,218 @@ async function fetchWithTimeout(
} }
} }
async function parseOpenAIStream(
response: Response,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> {
let data = ''
let finishReason = 'unknown'
let usage: AIRequestResult['usage']
const stream = await readSSE(response, (eventData) => {
let payload: OpenAIStreamPayload
try {
payload = JSON.parse(eventData) as OpenAIStreamPayload
} catch {
throw new Error('模型服务返回了无法解析的流式数据')
}
if (payload.error?.message) throw new Error(payload.error.message)
if (payload.usage) usage = toOpenAIUsage(payload.usage)
const choice = payload.choices?.[0]
if (choice?.finish_reason) finishReason = choice.finish_reason
const delta = choice?.delta?.content
if (typeof delta === 'string' && delta) {
data += delta
onDelta?.(delta)
}
})
if (!stream.sawData) {
let payload: OpenAIResponsePayload
try {
payload = JSON.parse(stream.rawBody) as OpenAIResponsePayload
} catch {
throw new Error('模型服务返回了无法解析的流式数据')
}
if (payload.error?.message) throw new Error(payload.error.message)
const content = openAIMessageText(payload.choices?.[0]?.message?.content)
if (content) onDelta?.(content)
return {
data: content,
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
usage: toOpenAIUsage(payload.usage)
}
}
return { data, finishReason, usage }
}
async function parseOpenAIResponsesStream(
response: Response,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> {
let data = ''
let completedResponse: OpenAIResponsesPayload | undefined
let usage: AIRequestResult['usage']
const stream = await readSSE(response, (eventData) => {
let payload: OpenAIResponsesStreamPayload
try {
payload = JSON.parse(eventData) as OpenAIResponsesStreamPayload
} catch {
throw new Error('模型服务返回了无法解析的 Responses 流式数据')
}
const errorMessage = payload.error?.message || payload.response?.error?.message
if (errorMessage) throw new Error(errorMessage)
if (payload.response?.usage) usage = toOpenAIResponsesUsage(payload.response.usage)
if (payload.type === 'response.output_text.delta' && typeof payload.delta === 'string') {
data += payload.delta
onDelta?.(payload.delta)
}
if (payload.type === 'response.completed') completedResponse = payload.response
if (payload.type === 'response.failed' || payload.type === 'response.incomplete') {
const reason = payload.response?.incomplete_details?.reason
throw new Error(reason ? `模型响应未完成:${reason}` : payload.message || '模型响应未完成')
}
})
if (!stream.sawData) {
let payload: OpenAIResponsesPayload
try {
payload = JSON.parse(stream.rawBody) as OpenAIResponsesPayload
} catch {
throw new Error('模型服务返回了无法解析的 Responses 流式数据')
}
if (payload.error?.message) throw new Error(payload.error.message)
const content = extractOpenAIResponsesText(payload)
if (content) onDelta?.(content)
return {
data: content,
finishReason: openAIResponsesFinishReason(payload) || 'unknown',
usage: toOpenAIResponsesUsage(payload.usage)
}
}
if (!data && completedResponse) {
data = extractOpenAIResponsesText(completedResponse)
if (data) onDelta?.(data)
}
return {
data,
finishReason: completedResponse
? openAIResponsesFinishReason(completedResponse) || 'unknown'
: 'unknown',
usage
}
}
async function readSSE(
response: Response,
onEvent: (eventData: string) => void
): Promise<{ sawData: boolean; rawBody: string }> {
if (!response.body) throw new Error('模型服务未返回可读取的流式响应')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let rawBody = ''
let sawData = false
let finished = false
const consumeEvent = (event: string): boolean => {
const eventData = event
.split(/\r\n|\r|\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => {
const value = line.slice(5)
return value.startsWith(' ') ? value.slice(1) : value
})
.join('\n')
if (!eventData) return false
sawData = true
rawBody = ''
if (eventData.trim() === '[DONE]') return true
onEvent(eventData)
return false
}
const consumeBuffer = (): void => {
while (!finished) {
const boundary = /(?:\r\n|\r|\n){2}/.exec(buffer)
if (!boundary) return
const event = buffer.slice(0, boundary.index)
buffer = buffer.slice(boundary.index + boundary[0].length)
finished = consumeEvent(event)
}
}
try {
while (!finished) {
const chunk = await reader.read()
if (chunk.done) {
const tail = decoder.decode()
buffer += tail
if (!sawData) rawBody += tail
consumeBuffer()
break
}
const text = decoder.decode(chunk.value, { stream: true })
buffer += text
if (!sawData) rawBody += text
consumeBuffer()
}
if (!finished && buffer.trim()) finished = consumeEvent(buffer)
if (finished) await reader.cancel().catch(() => undefined)
} catch (error) {
await reader.cancel(error).catch(() => undefined)
throw error
} finally {
reader.releaseLock()
}
return { sawData, rawBody }
}
function extractOpenAIResponsesText(payload: OpenAIResponsesPayload): string {
if (typeof payload.output_text === 'string') return payload.output_text
return (payload.output || [])
.flatMap((item) => item.content || [])
.filter((item) => item.type === 'output_text')
.map((item) => item.text || '')
.join('')
}
function openAIResponsesFinishReason(payload: OpenAIResponsesPayload): string | undefined {
const reason = payload.incomplete_details?.reason
if (reason === 'max_output_tokens') return 'max_tokens'
if (reason) return reason
if (payload.status === 'completed') return 'stop'
if (payload.status === 'failed') return 'error'
return undefined
}
function toOpenAIResponsesUsage(
usage: OpenAIResponsesPayload['usage']
): AIRequestResult['usage'] | undefined {
return usage
? {
input: usage.input_tokens,
output: usage.output_tokens,
total: usage.total_tokens,
estimated: false
}
: undefined
}
function toOpenAIUsage(
usage: OpenAIResponsePayload['usage']
): AIRequestResult['usage'] | undefined {
return usage
? {
input: usage.prompt_tokens,
output: usage.completion_tokens,
total: usage.total_tokens,
estimated: false
}
: undefined
}
async function parseJsonResponse<T>(response: Response): Promise<T> { async function parseJsonResponse<T>(response: Response): Promise<T> {
const body = await response.text() const body = await response.text()
try { try {
@@ -780,6 +1396,32 @@ function safeAIError(error: unknown): string {
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300) return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
} }
/** 判断解析失败是否因为上游返回了 HTML(例如网关 502 错误页)。 */
function isHtmlParseError(error: unknown): boolean {
return error instanceof Error && error.message.includes('网页而不是 JSON')
}
function aiProviderErrorDetails(error: unknown): {
errorCode?: string
errorStatus?: number
errorType?: string
} {
if (error instanceof AIProviderRequestError) {
return {
...(error.code ? { errorCode: error.code } : {}),
...(error.status !== undefined ? { errorStatus: error.status } : {}),
...(error.type ? { errorType: error.type } : {})
}
}
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : undefined
const status = Number(record?.status)
return {
...(typeof record?.code === 'string' ? { errorCode: record.code } : {}),
...(Number.isFinite(status) && status > 0 ? { errorStatus: status } : {}),
...(typeof record?.type === 'string' ? { errorType: record.type } : {})
}
}
function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } { function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } {
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl) const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP') if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP')
+309 -34
View File
@@ -4,6 +4,7 @@ import {
buildLocalAiSearchPlan, buildLocalAiSearchPlan,
inferAiSearchTimeRange, inferAiSearchTimeRange,
mergeAiSearchPlans, mergeAiSearchPlans,
parseAiQueryUnderstanding,
parseAiSearchPlan, parseAiSearchPlan,
type AiSearchAgentRun, type AiSearchAgentRun,
type AiSearchAgentTraceItem, type AiSearchAgentTraceItem,
@@ -26,6 +27,7 @@ import { runControlledSearchAgent, type AgentAction, type AgentToolResult } from
import { AIProviderService } from './ai-provider-service' import { AIProviderService } from './ai-provider-service'
import { KnowledgeSearchService } from '../knowledge/knowledge-search-service' import { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { resolveContact, type ContactResolutionScope } from './contact-resolution-service' import { resolveContact, type ContactResolutionScope } from './contact-resolution-service'
import type { ContactResolutionResult } from '../../shared/contact-resolution'
const DISPLAY_EVIDENCE_LIMIT = 8 const DISPLAY_EVIDENCE_LIMIT = 8
const AGENT_MESSAGE_LIMIT = 100 const AGENT_MESSAGE_LIMIT = 100
@@ -55,14 +57,32 @@ const contactScopeForIntent = (intent: AiSearchPlan['intent']): ContactResolutio
: 'person' : 'person'
const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean => const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean =>
intent === 'conversation_boundary' ||
intent === 'conversation_recall' || intent === 'conversation_recall' ||
intent === 'conversation_topic_search' || intent === 'conversation_topic_search' ||
intent === 'conversation_name_search' intent === 'conversation_name_search'
const isIdentityPlan = (plan: AiSearchPlan): boolean =>
isIdentityIntent(plan.intent) || (plan.mode === 'semantic' && Boolean(plan.contactQuery || plan.targetQuery))
export const isSuspiciousLocalPlan = (query: string, plan: AiSearchPlan): boolean => {
const hasRelationalShape = /我\s*(?:和|跟|与)|(?:和|跟|与)\s*我|第一次|最早|最后一次|最近一次/.test(query)
const hasBoundaryShape = /第一次|最早|最后一次|最近一次|上一次|什么时候开始/.test(query)
const hasInterpretiveShape = /熟起来|刚认识|答应|是不是|提过|后来|那个|事情/.test(query)
return (
(plan.intent === 'global_topic_search' && hasRelationalShape) ||
(hasInterpretiveShape && plan.intent !== 'conversation_boundary') ||
(hasBoundaryShape && plan.intent !== 'conversation_boundary') ||
(hasRelationalShape && !plan.contactQuery)
)
}
const retrievalModeForIntent = ( const retrievalModeForIntent = (
intent: AiSearchPlan['intent'] intent: AiSearchPlan['intent']
): AiSearchRetrievalContract['retrievalMode'] => ): AiSearchRetrievalContract['retrievalMode'] =>
intent === 'conversation_recall' intent === 'conversation_boundary'
? 'conversation_boundary'
: intent === 'conversation_recall'
? 'conversation_metadata' ? 'conversation_metadata'
: intent === 'conversation_topic_search' : intent === 'conversation_topic_search'
? 'conversation_topic_fts' ? 'conversation_topic_fts'
@@ -97,6 +117,11 @@ const conversationIdsForContacts = (contacts: Contact[]): string[] =>
const messageTime = (timestamp: number): string => const messageTime = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
const queryDate = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
}
const estimateTokens = (value: string): number => Math.ceil(value.length / 2) const estimateTokens = (value: string): number => Math.ceil(value.length / 2)
const emptyAggregation = (): AiSearchAggregation => ({ const emptyAggregation = (): AiSearchAggregation => ({
@@ -145,6 +170,34 @@ const isAbortError = (error: unknown): boolean =>
(error instanceof DOMException && error.name === 'AbortError') || (error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError') (error instanceof Error && error.name === 'AbortError')
const createDeltaBatcher = (
publish: (delta: string) => void,
intervalMs = 40
): { push: (delta: string) => void; close: (flushPending: boolean) => void } => {
let pending = ''
let timer: ReturnType<typeof setTimeout> | undefined
const flush = (): void => {
timer = undefined
if (!pending) return
const delta = pending
pending = ''
publish(delta)
}
return {
push: (delta: string): void => {
if (!delta) return
pending += delta
if (!timer) timer = setTimeout(flush, intervalMs)
},
close: (flushPending: boolean): void => {
if (timer) clearTimeout(timer)
timer = undefined
if (flushPending) flush()
else pending = ''
}
}
}
/** /**
* Main-process search orchestrator. It owns the only transition from raw * Main-process search orchestrator. It owns the only transition from raw
* candidates to Final Evidence; the AI and Renderer never receive a wider * candidates to Final Evidence; the AI and Renderer never receive a wider
@@ -231,6 +284,7 @@ export class AiSearchPipelineService {
const localPlan = buildLocalAiSearchPlan(request.text) const localPlan = buildLocalAiSearchPlan(request.text)
let plan: AiSearchPlan = { let plan: AiSearchPlan = {
...localPlan, ...localPlan,
mode: 'structured',
scopeLabel: aiSearchScopeLabel( scopeLabel: aiSearchScopeLabel(
localPlan.intent === 'global_group_topic_search' && request.scope === 'global' localPlan.intent === 'global_group_topic_search' && request.scope === 'global'
? 'groups' ? 'groups'
@@ -260,14 +314,106 @@ export class AiSearchPipelineService {
const contactResolutionStartedAt = Date.now() const contactResolutionStartedAt = Date.now()
const contacts = chat.isReady() ? await chat.listContactsAsync() : [] const contacts = chat.isReady() ? await chat.listContactsAsync() : []
signal.throwIfAborted() signal.throwIfAborted()
const shouldUseQueryUnderstanding =
localPlan.intent === 'general' || isSuspiciousLocalPlan(request.text, plan)
let queryUnderstandingSource: 'local' | 'ai' = 'local'
let queryUnderstandingError: string | undefined
if (shouldUseQueryUnderstanding && aiSearchAvailable) {
let parserResult: Awaited<ReturnType<AiSearchPipelineService['chatForSearchRequest']>>
let parserException: string | undefined
try {
parserResult = await this.chatForSearchRequest(
request.requestId,
aiConfig.providerId,
aiConfig.model,
[
{
role: 'system',
content:
'你是 TraceMemo Query Compiler。只输出严格 JSON,不回答问题、不搜索聊天、不编造联系人。mode 只能是 structured、semantic、clarification。structured 复用已有 intent;conversation_boundary 允许 boundary=first|last 和 projection=time|content|time_and_content。semantic 用 semanticQuery(1-200 字)、queryVariants(最多 4 个)、可选 targetQuery(联系人显示名)和 answerMode=extract|synthesis;clarification 仅用于确实缺少必要上下文且 requiresClarification=true。只允许输出 schema 中字段:mode、intent、contactQuery、topicQuery、boundary、projection、targetQuery、semanticQuery、queryVariants、answerMode、confidence、requiresClarification、clarificationReason。禁止 conversationId、wxid、messageId、SQL、路径、Evidence、时间戳、Tool、Provider。',
},
{
role: 'user',
content: `用户问题:${request.text}\n当前界面范围:${request.scope}`
}
],
signal
)
} catch (error) {
parserException = error instanceof Error ? error.message : '查询语义解析器执行失败'
parserResult = {
success: false,
error: parserException
}
}
const understanding = parserResult.success && parserResult.data
? parseAiQueryUnderstanding(parserResult.data)
: null
if (understanding && understanding.confidence >= 0.6 && !understanding.requiresClarification) {
queryUnderstandingSource = 'ai'
plan = {
...plan,
intent: understanding.intent,
mode: understanding.mode || 'structured',
contactQuery: understanding.contactQuery || understanding.targetQuery,
targetQuery: understanding.targetQuery,
semanticQuery: understanding.semanticQuery,
queryVariants: understanding.queryVariants,
answerMode: understanding.answerMode,
topicQuery: understanding.topicQuery,
boundary: understanding.boundary,
projection: understanding.projection,
keywords: understanding.topicQuery ? [understanding.topicQuery] : [],
variants: understanding.topicQuery ? [understanding.topicQuery] : [],
source: 'hybrid'
}
} else {
queryUnderstandingError =
understanding?.clarificationReason || parserException || parserResult.error || '无法可靠理解查询意图'
}
} else if (shouldUseQueryUnderstanding) {
queryUnderstandingError = aiConfig.configured ? '查询语义解析器暂时不可用' : '尚未配置可用 AI 模型'
}
const selectedContact = const selectedContact =
request.scope === 'conversation' && request.conversationId request.scope === 'conversation' && request.conversationId
? contacts.find((contact) => contact.md5 === request.conversationId) ? contacts.find((contact) => contact.md5 === request.conversationId)
: undefined : undefined
const sourceContacts = this.scopeContacts(contacts, request, selectedContact, plan.intent) const sourceContacts = this.scopeContacts(contacts, request, selectedContact, plan.intent)
if (!sourceContacts.length) throw new Error('当前搜索范围没有可用会话') if (!sourceContacts.length && !queryUnderstandingError) throw new Error('当前搜索范围没有可用会话')
const contactResolution = plan.contactQuery if (queryUnderstandingError) {
? resolveContact(plan.contactQuery, sourceContacts, contactScopeForIntent(plan.intent)) const retrieval: AiSearchRetrievalContract = {
intent: plan.intent,
timeRange: plan.timeRange,
retrievalMode: 'unresolved_identity',
candidateCount: 0,
uniqueCandidateCount: 0,
sourceCoverage: 'unknown',
isComplete: false,
fallbackUsed: false,
suspicious: true
}
const error = `我没有完全理解你想怎么查:${queryUnderstandingError}`
emit({ stage: 'query_understanding', status: 'error', message: error, plan, error })
return {
requestId: request.requestId,
status: 'understanding_failed',
plan,
knowledge: { source: 'knowledge', state: 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, totalMessages: 0 },
candidateEvidenceCount: 0,
retrieval,
evidence: [],
evidenceCollection: [],
contextEvidenceCount: 0,
aggregation: emptyAggregation(),
agent: { mode: 'fallback', toolCalls: 0, trace: [], fallbackReason: error },
timings: snapshotTimings(),
error,
errorStage: 'query_understanding',
elapsedMs: Date.now() - startedAt
}
}
const contactResolution = (plan.contactQuery || plan.targetQuery)
? resolveContact(plan.contactQuery || plan.targetQuery || '', sourceContacts, contactScopeForIntent(plan.intent))
: undefined : undefined
const resolvedContact = const resolvedContact =
selectedContact || selectedContact ||
@@ -285,7 +431,7 @@ export class AiSearchPipelineService {
contactNames: resolvedContact ? [contactLabel(resolvedContact)] : [] contactNames: resolvedContact ? [contactLabel(resolvedContact)] : []
} }
const conversationIds = const conversationIds =
isIdentityIntent(plan.intent) && resolvedContact isIdentityPlan(plan) && resolvedContact
? [resolvedContact.md5] ? [resolvedContact.md5]
: request.scope === 'global' && plan.intent !== 'global_group_topic_search' : request.scope === 'global' && plan.intent !== 'global_group_topic_search'
? undefined ? undefined
@@ -295,7 +441,7 @@ export class AiSearchPipelineService {
let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] } let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] }
let candidateEvidence: AiSearchPipelineEvidence[] let candidateEvidence: AiSearchPipelineEvidence[]
let searchResult: KnowledgeSearchIpcResult let searchResult: KnowledgeSearchIpcResult
const agentOutcome = aiSearchAvailable const agentOutcome = aiSearchAvailable && plan.intent !== 'conversation_boundary' && plan.mode !== 'semantic'
? await this.runAgentSearch( ? await this.runAgentSearch(
request, request,
plan, plan,
@@ -359,8 +505,8 @@ export class AiSearchPipelineService {
timings.rankingMs += agentOutcome.searchTimings.rankingMs timings.rankingMs += agentOutcome.searchTimings.rankingMs
} else { } else {
const deterministicIdentityRetrieval = const deterministicIdentityRetrieval =
Boolean(resolvedContact) && (isIdentityIntent(plan.intent) || Boolean(selectedContact)) Boolean(resolvedContact) && (isIdentityPlan(plan) || Boolean(selectedContact))
const unresolvedIdentity = isIdentityIntent(plan.intent) && !resolvedContact const unresolvedIdentity = isIdentityPlan(plan) && !resolvedContact
const fallbackReason = confirmedConversationNeedsFallback const fallbackReason = confirmedConversationNeedsFallback
? selectedContact ? selectedContact
? '已选择会话的 Agent 未产生可读取消息,已按该会话执行确定性检索' ? '已选择会话的 Agent 未产生可读取消息,已按该会话执行确定性检索'
@@ -395,7 +541,7 @@ export class AiSearchPipelineService {
agentTrace: agent.trace[0], agentTrace: agent.trace[0],
timings: snapshotTimings() timings: snapshotTimings()
}) })
if (aiSearchAvailable && !deterministicIdentityRetrieval && !unresolvedIdentity) { if (aiSearchAvailable && plan.mode !== 'semantic' && !deterministicIdentityRetrieval && !unresolvedIdentity) {
const planningStartedAt = Date.now() const planningStartedAt = Date.now()
const planning = await this.chatForSearchRequest( const planning = await this.chatForSearchRequest(
request.requestId, request.requestId,
@@ -440,6 +586,9 @@ export class AiSearchPipelineService {
state: 'ready', state: 'ready',
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
// Legacy 路径不消费 freshness 口径;显式写 null,避免与 Query Agent 的覆盖语义混淆。
indexLatestAt: null,
sourceLatestAt: null,
totalMessages: 0, totalMessages: 0,
evidence: [], evidence: [],
timings: emptyKnowledgeSearchTimings() timings: emptyKnowledgeSearchTimings()
@@ -448,22 +597,46 @@ export class AiSearchPipelineService {
} else { } else {
const knowledgeSearchStartedAt = Date.now() const knowledgeSearchStartedAt = Date.now()
const deterministicTerms = const deterministicTerms =
plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search' plan.mode === 'semantic'
? [] ? Array.from(new Set([plan.semanticQuery, ...(plan.queryVariants || [])].filter((term): term is string => Boolean(term)))).slice(0, 5)
: plan.intent === 'conversation_topic_search' : plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search'
? plan.topicQuery ? []
? [plan.topicQuery] : plan.intent === 'conversation_topic_search'
: [] ? plan.topicQuery
: Array.from(new Set([...plan.keywords, ...plan.variants])) ? [plan.topicQuery]
searchResult = await this.knowledge.search({ : []
text: request.text, : Array.from(new Set([...plan.keywords, ...plan.variants]))
terms: deterministicTerms, const semanticResults: KnowledgeSearchIpcResult[] = []
retrievalSessionId: request.requestId, const termsToSearch: string[][] =
conversationIds, plan.mode === 'semantic' ? deterministicTerms.map((term) => [term]) : [deterministicTerms]
startTime: plan.timeRange.startTime, for (const terms of termsToSearch) {
endTime: plan.timeRange.endTime, semanticResults.push(await this.knowledge.search({
limit: 240 text: request.text,
}) terms,
retrievalSessionId: request.requestId,
conversationIds,
startTime: plan.timeRange.startTime,
endTime: plan.timeRange.endTime,
conversationBoundary: plan.boundary,
limit: 240
}))
}
const firstResult = semanticResults[0]
const mergedEvidence = Array.from(
new Map(semanticResults.flatMap((result) => result.evidence).map((item) => [`${item.conversationId}\u0000${item.messageId}`, item])).values()
)
searchResult = {
...(firstResult || {
source: 'knowledge', state: 'ready', indexedMessageCount: 0, indexedChunkCount: 0,
totalMessages: 0, evidence: [], timings: emptyKnowledgeSearchTimings()
}),
evidence: mergedEvidence,
indexedMessageCount: Math.max(...semanticResults.map((result) => result.indexedMessageCount), 0),
indexedChunkCount: Math.max(...semanticResults.map((result) => result.indexedChunkCount), 0),
totalMessages: Math.max(...semanticResults.map((result) => result.totalMessages), 0),
conversationRetrieval: semanticResults.find((result) => result.conversationRetrieval)?.conversationRetrieval,
voiceCoverage: semanticResults.find((result) => result.voiceCoverage)?.voiceCoverage
}
signal.throwIfAborted() signal.throwIfAborted()
timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt
candidateEvidence = this.toPipelineEvidence(searchResult, contacts) candidateEvidence = this.toPipelineEvidence(searchResult, contacts)
@@ -488,7 +661,7 @@ export class AiSearchPipelineService {
emit({ emit({
stage: 'query_understanding', stage: 'query_understanding',
status: 'completed', status: 'completed',
message: '已理解搜索条件', message: queryUnderstandingSource === 'ai' ? '已通过查询语义解析器理解搜索条件' : '已理解搜索条件',
plan, plan,
timings: snapshotTimings() timings: snapshotTimings()
}) })
@@ -552,7 +725,8 @@ export class AiSearchPipelineService {
resolvedContact, resolvedContact,
searchResult, searchResult,
candidateEvidence, candidateEvidence,
agent agent,
contactResolution
) )
if (retrieval.suspicious && resolvedContact) { if (retrieval.suspicious && resolvedContact) {
// An identity route that somehow yielded 0/1 records is never allowed // An identity route that somehow yielded 0/1 records is never allowed
@@ -565,6 +739,7 @@ export class AiSearchPipelineService {
conversationIds: [resolvedContact.md5], conversationIds: [resolvedContact.md5],
startTime: plan.timeRange.startTime, startTime: plan.timeRange.startTime,
endTime: plan.timeRange.endTime, endTime: plan.timeRange.endTime,
conversationBoundary: plan.boundary,
limit: 240 limit: 240
}) })
signal.throwIfAborted() signal.throwIfAborted()
@@ -575,7 +750,8 @@ export class AiSearchPipelineService {
resolvedContact, resolvedContact,
searchResult, searchResult,
candidateEvidence, candidateEvidence,
agent agent,
contactResolution
) )
} }
@@ -689,7 +865,30 @@ export class AiSearchPipelineService {
timings: snapshotTimings(), timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt elapsedMs: Date.now() - startedAt
} }
if (plan.intent === 'conversation_boundary' && retrieval.identityResolution !== 'resolved') {
const ambiguous = retrieval.identityResolution === 'ambiguous'
const error = ambiguous
? `联系人“${plan.contactQuery || ''}”存在多个匹配,请先确认具体联系人。`
: `没有确认联系人“${plan.contactQuery || ''}”,无法查询会话边界。`
return {
...baseResult,
status: ambiguous ? 'ambiguous_contact' : 'contact_not_found',
error,
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
}
if (!evidence.length) { if (!evidence.length) {
if (plan.intent === 'conversation_boundary') {
const error = `已确认联系人“${plan.contactQuery || ''}”,但当前知识库没有可读取的聊天消息。`
return {
...baseResult,
status: 'no_messages',
error,
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
}
emit({ emit({
stage: 'completed', stage: 'completed',
status: 'completed', status: 'completed',
@@ -706,6 +905,51 @@ export class AiSearchPipelineService {
} }
} }
if (plan.intent === 'conversation_boundary') {
const item = evidence[0]
const boundaryLabel = plan.boundary === 'first' ? '最早' : '最后'
const coverageNote = retrieval.isComplete
? '当前知识库已完成该会话的可读取记录覆盖。'
: '当前知识库覆盖不完整,不能据此确认历史上的第一次或最后一次交流。'
const projection = plan.projection || 'time'
const content = item.sourceKind === 'text'
? item.text || '(文本消息为空)'
: item.sourceKind === 'voice'
? item.text ? `语音转写:${item.text}` : '语音消息(没有可用转写)'
: item.sourceKind === 'file'
? '文件消息(没有可展示文本)'
: item.sourceKind === 'image'
? '图片消息(没有可展示文本)'
: item.sourceKind === 'video'
? '视频消息(没有可展示文本)'
: item.sourceKind === 'sticker'
? '表情消息(没有可展示文本)'
: item.sourceKind === 'link'
? '链接消息(没有可展示文本)'
: '非文本消息(没有可展示文本)'
const timeLine = `在 TraceMemo 当前可读取的聊天记录中,你和${plan.contactQuery || item.conversationName} ${boundaryLabel}的一条聊天记录出现在 ${messageTime(item.timestamp)}。`
const contentLine = `${item.sender}:${content} [E1]`
const answer = projection === 'content'
? `在 TraceMemo 当前可读取的聊天记录中,你和${plan.contactQuery || item.conversationName} ${boundaryLabel}的一条聊天记录内容是:\n${contentLine}\n${coverageNote}`
: `${timeLine}\n${contentLine}\n${coverageNote}`
emit({
stage: 'completed',
status: 'completed',
message: '已确定会话边界并生成可追溯答案',
plan,
stats: { matchedMessages: 1, evidenceCount: 1 },
timings: snapshotTimings()
})
return {
...baseResult,
status: 'completed',
answer,
citationValidation: { status: 'valid', invalidCitationIds: [] },
timings: snapshotTimings(),
elapsedMs: Date.now() - startedAt
}
}
if (retrieval.suspicious) { if (retrieval.suspicious) {
const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。' const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。'
emit({ emit({
@@ -791,6 +1035,23 @@ export class AiSearchPipelineService {
timings: snapshotTimings() timings: snapshotTimings()
}) })
const aiGenerationStartedAt = Date.now() const aiGenerationStartedAt = Date.now()
const answerDeltaBatcher = createDeltaBatcher((answerDelta) =>
emit({
stage: 'ai_generating',
status: 'running',
message: '正在生成带来源的回答',
answerDelta,
plan,
modelName: aiConfig.modelName,
stats: {
matchedMessages: evidenceBuild.candidateCount,
evidenceCount: evidence.length,
contextEvidenceCount: evidence.length,
tokenEstimate
},
timings: snapshotTimings()
})
)
const answer = await this.chatForSearchRequest( const answer = await this.chatForSearchRequest(
request.requestId, request.requestId,
aiConfig.providerId, aiConfig.providerId,
@@ -803,8 +1064,9 @@ export class AiSearchPipelineService {
}, },
{ role: 'user', content: prompt } { role: 'user', content: prompt }
], ],
signal signal,
) answerDeltaBatcher.push
).finally(() => answerDeltaBatcher.close(!signal.aborted))
signal.throwIfAborted() signal.throwIfAborted()
timings.aiGenerationMs = Date.now() - aiGenerationStartedAt timings.aiGenerationMs = Date.now() - aiGenerationStartedAt
if (!answer.success || !answer.data) { if (!answer.success || !answer.data) {
@@ -1009,13 +1271,14 @@ export class AiSearchPipelineService {
providerId: string | undefined, providerId: string | undefined,
modelId: string, modelId: string,
messages: Array<{ role: string; content: string }>, messages: Array<{ role: string; content: string }>,
signal: AbortSignal signal: AbortSignal,
onDelta?: (delta: string) => void
): ReturnType<AIProviderService['chat']> { ): ReturnType<AIProviderService['chat']> {
if (!this.canUseAiForRequest(requestId, providerId)) { if (!this.canUseAiForRequest(requestId, providerId)) {
return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' } return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' }
} }
signal.throwIfAborted() signal.throwIfAborted()
return this.aiProvider.chat(messages, { providerId, modelId }, signal) return this.aiProvider.chat(messages, { providerId, modelId }, signal, onDelta)
} }
private clearPendingAuthorization(requestId: string): void { private clearPendingAuthorization(requestId: string): void {
@@ -1119,6 +1382,8 @@ export class AiSearchPipelineService {
state: 'unavailable', state: 'unavailable',
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
indexLatestAt: null,
sourceLatestAt: null,
totalMessages: 0, totalMessages: 0,
evidence: [], evidence: [],
timings: emptyKnowledgeSearchTimings() timings: emptyKnowledgeSearchTimings()
@@ -1634,6 +1899,7 @@ export class AiSearchPipelineService {
: '' : ''
return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel} return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel}
用户问题:${query} 用户问题:${query}
当前查询实际时间范围:${plan.timeRange.startTime === undefined ? '未限定开始日期' : queryDate(plan.timeRange.startTime)} 至 ${plan.timeRange.endTime === undefined ? '当前时刻' : queryDate(plan.timeRange.endTime)}。回答只能基于这个程序确定的时间范围,不得根据“上个月”“去年”等原句自行推算其他年份。
检索意图:${aiSearchIntentLabel(plan.intent)} 检索意图:${aiSearchIntentLabel(plan.intent)}
检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'} 检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'}
检索范围消息总数:${totalMessages} 检索范围消息总数:${totalMessages}
@@ -1654,9 +1920,10 @@ ${context}`
resolvedContact: Contact | undefined, resolvedContact: Contact | undefined,
result: KnowledgeSearchIpcResult, result: KnowledgeSearchIpcResult,
candidates: AiSearchPipelineEvidence[], candidates: AiSearchPipelineEvidence[],
agent: AiSearchAgentRun agent: AiSearchAgentRun,
contactResolution?: ContactResolutionResult
): AiSearchRetrievalContract { ): AiSearchRetrievalContract {
const identity = isIdentityIntent(plan.intent) const identity = isIdentityPlan(plan)
const conversationRetrieval = result.conversationRetrieval const conversationRetrieval = result.conversationRetrieval
const sourceMessageCount = const sourceMessageCount =
conversationRetrieval?.totalMessages ?? conversationRetrieval?.totalMessages ??
@@ -1696,6 +1963,14 @@ ${context}`
fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback', fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback',
fallbackReason: agent.fallbackReason || result.fallbackReason, fallbackReason: agent.fallbackReason || result.fallbackReason,
voiceCoverage: result.voiceCoverage, voiceCoverage: result.voiceCoverage,
identityResolution: identity
? resolvedContact
? 'resolved'
: contactResolution?.ambiguous
? 'ambiguous'
: 'not_found'
: 'not_required',
boundary: plan.boundary,
suspicious: suspicious:
plan.intent === 'conversation_recall' && plan.intent === 'conversation_recall' &&
Boolean(resolvedContact) && Boolean(resolvedContact) &&
+276
View File
@@ -0,0 +1,276 @@
import type { AiSearchPipelineRequest, AiSearchPipelineResult } from '../../shared/ai-search'
import type {
AskWechatEvidenceItem,
AskWechatOutcome,
AskWechatFallbackReason,
AskWechatQueryRequest,
AskWechatQueryResult,
AskWechatScope,
AskWechatStats,
QueryAgentDiagnostics,
QueryAgentEntry,
QueryAgentProgressEvent
} from '../../shared/query-agent'
import { QueryAgentService, type QueryAgentResult } from './query-agent-service'
import { QueryAgentConversationMemory } from './query-agent-conversation-memory'
/** Provider 不可用时的用户可见文案。不下发内部错误、stack、raw provider response。 */
const PROVIDER_UNAVAILABLE_MESSAGE = '当前 AI 查询服务暂时不可用,请稍后再试。'
/** Runtime 不可恢复错误(且无法回退)时的用户可见文案。 */
const RUNTIME_FAILURE_MESSAGE = '本次查询没有完成,请稍后再试或换一种问法。'
const EMPTY_QUESTION_MESSAGE = '请先输入想了解的问题。'
export type AskWechatLegacyRunner = (
request: AiSearchPipelineRequest
) => Promise<AiSearchPipelineResult>
export interface AskWechatLogRecord {
level: 'info' | 'warn' | 'error'
message: string
details?: Record<string, unknown>
}
export interface AskWechatServiceOptions {
/** 入口标记,只用于诊断。 */
entry: QueryAgentEntry
/**
* 仅桌面使用:Runtime 不可恢复错误时允许回退 Legacy。
* 不传表示该入口没有 Legacy fallback(Agent Hub 就没传)。
*/
runLegacy?: AskWechatLegacyRunner
log?: (record: AskWechatLogRecord) => void
memory?: QueryAgentConversationMemory
}
/**
* 「问问微信」/ Agent Hub 查询入口的 Adapter。
*
* 职责:调用 Query Agent Runtime、维护有界的澄清上下文、把结构化结果映射成可展示的形状、
* 并决定是否允许 Legacy fallback。它**不含**任何 prompt / tool / temporalBasis 语义。
*
* Fallback 规则:只在 Runtime 不可恢复错误(异常 / tool limit)时回退 Legacy。
* Provider 失败不回退(Legacy 用同一个 Provider,只会更慢);0 结果、模型说"没找到"、
* 要求澄清、答案很短 —— 一律不回退。
*/
export class AskWechatService {
private readonly memory: QueryAgentConversationMemory
constructor(
private readonly runtime: QueryAgentService,
private readonly options: AskWechatServiceOptions
) {
this.memory = options.memory || new QueryAgentConversationMemory()
}
async ask(
request: AskWechatQueryRequest,
conversationKey = 'default',
/**
* 真实 Runtime 进度(ADDITIVE)。由 IPC 层转发给 renderer。
* 不传时行为与之前完全一致;回调抛异常不会影响查询本身(Runtime 侧已兜住)。
*/
onProgress?: (event: QueryAgentProgressEvent) => void
): Promise<AskWechatQueryResult> {
const startedAt = Date.now()
const question = String(request.text || '').trim()
let result: QueryAgentResult
try {
result = await this.runtime.run(question, {
history: this.memory.history(conversationKey),
// 搜索范围:UI 决定的数据边界,Runtime 透传给 Engine 并结构性强制。
...(request.scope ? { conversationScope: request.scope } : {}),
...(onProgress ? { onProgress } : {})
})
} catch {
// Runtime 抛出未分类异常 = UNEXPECTED_INTERNAL_ERROR,允许 Legacy fallback。
const diagnostics = this.diagnostics(
{ provider: '', model: '', modelCallCount: 0, toolCallCount: 0, traces: [] },
startedAt,
'runtime_error'
)
this.writeLog('error', `Query Agent Runtime 异常(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
}
if (result.errorKind === 'invalid_question') {
return {
engine: 'query-agent',
status: 'error',
message: EMPTY_QUESTION_MESSAGE,
diagnostics: this.diagnostics(result, startedAt, 'invalid_question')
}
}
if (result.errorKind === 'provider_unavailable' || result.errorKind === 'provider_failure') {
const outcome: AskWechatOutcome = result.errorKind
const diagnostics = this.diagnostics(result, startedAt, outcome)
this.writeLog('warn', `查询 Provider 不可用(${this.options.entry})`, diagnostics)
return {
engine: 'query-agent',
status: 'provider_unavailable',
message: PROVIDER_UNAVAILABLE_MESSAGE,
diagnostics
}
}
if (result.errorKind === 'tool_limit') {
const diagnostics = this.diagnostics(result, startedAt, 'tool_limit')
this.writeLog('warn', `查询超出工具调用上限(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
}
if (!result.answer?.trim()) {
const diagnostics = this.diagnostics(result, startedAt, 'runtime_error')
this.writeLog('warn', `Query Agent 未返回回答(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
}
const answer = result.answer.trim()
// 澄清回答也记录:下一句("是 BOBO")需要接得上上文。
this.memory.record(conversationKey, question, answer)
const diagnostics = this.diagnostics(result, startedAt, 'answered')
this.writeLog('info', `Query Agent 回答完成(${this.options.entry})`, diagnostics)
return {
engine: 'query-agent',
status: 'answered',
answer,
// 直接透传 Runtime 收集的真实证据:UI 不允许从 answer 文本反解析。
evidence: (result.evidence || []) as AskWechatEvidenceItem[],
stats: buildAskWechatStats(result, request.scope, startedAt),
diagnostics
}
}
/** 桌面点「新问题」时清掉当前会话的澄清上下文。 */
forgetConversation(conversationKey = 'default'): void {
this.memory.forget(conversationKey)
}
private async fallback(
request: AskWechatQueryRequest,
reason: AskWechatFallbackReason,
diagnostics: QueryAgentDiagnostics
): Promise<AskWechatQueryResult> {
if (!this.options.runLegacy) {
return {
engine: 'query-agent',
status: 'error',
message: RUNTIME_FAILURE_MESSAGE,
diagnostics
}
}
try {
const legacyResult = await this.options.runLegacy({
scope: 'global',
range: 'all',
...request.legacy,
requestId: request.requestId,
text: request.text
})
this.writeLog('warn', `Query Agent 失败后回退 Legacy(${this.options.entry})`, diagnostics, reason)
return { engine: 'legacy', status: 'legacy', reason, result: legacyResult }
} catch {
this.writeLog('error', `Legacy fallback 也失败(${this.options.entry})`, diagnostics, reason)
return {
engine: 'query-agent',
status: 'error',
message: RUNTIME_FAILURE_MESSAGE,
diagnostics
}
}
}
private diagnostics(
result: Pick<
QueryAgentResult,
'provider' | 'model' | 'modelCallCount' | 'toolCallCount' | 'traces'
> &
Partial<Pick<QueryAgentResult, 'totalMs'>>,
startedAt: number,
outcome: AskWechatOutcome
): QueryAgentDiagnostics {
return {
entry: this.options.entry,
provider: result.provider,
model: result.model,
modelCallCount: result.modelCallCount,
toolCallCount: result.toolCallCount,
tools: (result.traces || []).map((trace) => trace.toolName),
totalMs: result.totalMs || Date.now() - startedAt,
outcome
}
}
private writeLog(
level: AskWechatLogRecord['level'],
message: string,
details: QueryAgentDiagnostics,
fallbackReason?: AskWechatFallbackReason
): void {
// 展开成匿名对象:只传形态字段(入口 / provider / 次数 / 耗时 / 结果),不含聊天内容。
this.options.log?.({
level,
message,
details: { ...details, ...(fallbackReason ? { fallbackReason } : {}) }
})
}
}
/**
* 顶部统计只用**真实执行**产生的数字:三个 Query Tool 的数据来源不同
* (`query_messages` 直读 WCDB,`search_messages` / `conversation_overview` 走检索),
* 统计口径必须各自如实,不能套用统一的"已收录 / 读取"文案。
*/
export function buildAskWechatStats(
result: Pick<
QueryAgentResult,
'traces' | 'evidence' | 'modelCallCount' | 'toolCallCount' | 'totalMs' | 'modelDurationsMs'
>,
scope: AskWechatScope | undefined,
startedAt: number
): AskWechatStats {
let messageCount = 0
let matchedCount = 0
let overviewSourceCount = 0
const tools: string[] = []
for (const trace of result.traces || []) {
tools.push(trace.toolName)
if (trace.toolName === 'query_messages') {
messageCount += trace.resultCount || 0
} else {
matchedCount += trace.evidenceCount || 0
overviewSourceCount += trace.sourceMessageCount || 0
}
}
const totalMs = result.totalMs || Date.now() - startedAt
// 真实拆解:模型总耗时直接来自每次模型调用的测量;本地查询 = 所有 Tool 的 durationMs 之和。
// 两者不互相推算(用 total - model 反推会把"框架开销"混进"本地查询",那是另一种谎)。
const modelDurationsMs = (result.modelDurationsMs || []).filter((value) =>
Number.isFinite(value)
)
const toolDurationsMs = (result.traces || [])
.map((trace) => trace.durationMs)
.filter((value) => Number.isFinite(value))
return {
tools,
reads: {
messageCount,
matchedCount,
overviewSourceCount,
evidenceCount: (result.evidence || []).length
},
...(scope
? { scope: { kind: scope.scope.kind, ...(scope.label ? { label: scope.label } : {}) } }
: {}),
modelCallCount: result.modelCallCount,
toolCallCount: result.toolCallCount,
totalMs,
timings: {
modelMs: modelDurationsMs.reduce((sum, value) => sum + value, 0),
localQueryMs: toolDurationsMs.reduce((sum, value) => sum + value, 0),
totalMs,
...(modelDurationsMs.length ? { modelDurationsMs } : {}),
...(toolDurationsMs.length ? { toolDurationsMs } : {})
}
}
}
+228 -11
View File
@@ -1,3 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'
import { WechatDb, WechatMessage } from '../wechat-db' import { WechatDb, WechatMessage } from '../wechat-db'
import { import {
parseImageBufferDataUrlFromRow, parseImageBufferDataUrlFromRow,
@@ -16,6 +17,11 @@ import {
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service' import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality' import type { ExportImageQuality } from '../../shared/image-quality'
import { wcdbDebugLog } from '../wcdb-debug' import { wcdbDebugLog } from '../wcdb-debug'
import {
buildContactSearchIndex,
filterContactSearchIndex,
type ContactSearchIndex
} from '../../shared/contact-search'
export function getCurrentKey(): string { export function getCurrentKey(): string {
if (!dbRef) return '' if (!dbRef) return ''
@@ -44,6 +50,10 @@ export interface FormattedContact {
avatar?: string avatar?: string
wechatNickname?: string wechatNickname?: string
remark?: string remark?: string
alias?: string
wechatId?: string
wxid?: string
legacyIdentifier?: string
isFolded?: boolean isFolded?: boolean
isMuted?: boolean isMuted?: boolean
} }
@@ -86,6 +96,7 @@ export interface FormattedMessage {
export interface GroupSnapshot { export interface GroupSnapshot {
roomId: string roomId: string
memberCount: number memberCount: number
groupName?: string
members: { members: {
wxid: string wxid: string
nickname: string nickname: string
@@ -96,6 +107,15 @@ export interface GroupSnapshot {
}[] }[]
} }
export interface GroupMembershipSnapshot {
roomId: string
memberIds: string[]
}
export interface GroupMembershipBatchSnapshot extends GroupMembershipSnapshot {
status: 'ok' | 'not_found'
}
const MSG_TYPE_DICT: Record<number, string> = { const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本', 1: '普通文本',
3: '图片', 3: '图片',
@@ -125,6 +145,7 @@ function normalizeMsgType(value: string | number | undefined): number {
} }
let dbRef: WechatDb | null = null let dbRef: WechatDb | null = null
let contactSearchIndexCache: { signature: string; index: ContactSearchIndex } | null = null
let shutdownRequested = false let shutdownRequested = false
export interface ImageMessageReference { export interface ImageMessageReference {
@@ -136,7 +157,9 @@ export interface ImageMessageReference {
} }
const imageMessageReferences = new Map<string, ImageMessageReference | null>() const imageMessageReferences = new Map<string, ImageMessageReference | null>()
let imageReferenceScope = randomUUID()
/** Replace the active database and invalidate connection-scoped lookup caches. */
export function setChatDb(db: WechatDb | null): boolean { export function setChatDb(db: WechatDb | null): boolean {
if (shutdownRequested) { if (shutdownRequested) {
db?.close() db?.close()
@@ -144,7 +167,9 @@ export function setChatDb(db: WechatDb | null): boolean {
} }
dbRef?.close() dbRef?.close()
dbRef = db dbRef = db
contactSearchIndexCache = null
imageMessageReferences.clear() imageMessageReferences.clear()
imageReferenceScope = randomUUID()
return true return true
} }
@@ -171,12 +196,67 @@ export function isReady(): boolean {
return dbRef !== null return dbRef !== null
} }
/** Session 行的时间字段可能是秒,也可能是毫秒;1e11 以下按秒换算。 */
function sessionTimeToEpochMs(value: unknown): number | null {
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
if (!Number.isFinite(numeric) || numeric <= 0) return null
return Math.round(numeric < 1e11 ? numeric * 1000 : numeric)
}
/**
* 源数据(WCDB Session)里最新的活跃时间(epoch ms)。
*
* Session 列表本来就带着 `last_timestamp`,所以这是**零额外 WCDB 调用**的 freshness 信号:
* 有了它才能区分「源数据本来就没有新消息」和「有新消息但派生索引还没追到」。
* 只读取会话级的活跃时间戳,不读取任何消息内容。
*/
export function getSourceLatestActivityMs(): number | null {
if (!dbRef) return null
try {
let latest = 0
for (const session of dbRef.getWcdb4Client().getSessions()) {
const value = sessionTimeToEpochMs(session.raw?.['last_timestamp'])
if (value !== null && value > latest) latest = value
}
return latest > 0 ? latest : null
} catch {
return null
}
}
/**
* 每个会话在源数据里最后的活跃时间(epoch ms),按会话 md5 索引。
*
* 与 `getSourceLatestActivityMs` 同源(Session 行的 `last_timestamp`),同样是**零额外
* WCDB 调用**。增量索引 pass 用它判断「这个会话自上次索引以来有没有新消息」,
* 从而整段跳过没有变化的会话 —— 这是增量同步名副其实的前提。
*/
export function getConversationActivityMs(): Map<string, number> {
const result = new Map<string, number>()
if (!dbRef) return result
try {
for (const session of dbRef.getWcdb4Client().getSessions()) {
const username = typeof session.username === 'string' ? session.username : ''
if (!username) continue
const value = sessionTimeToEpochMs(session.raw?.['last_timestamp'])
if (value === null) continue
const md5 = dbRef.md5(username)
if (!md5) continue
const existing = result.get(md5)
if (existing === undefined || value > existing) result.set(md5, value)
}
} catch {
return result
}
return result
}
export function listContacts(filter?: string): FormattedContact[] { export function listContacts(filter?: string): FormattedContact[] {
if (!dbRef) return [] if (!dbRef) return []
const contacts: FormattedContact[] = [] const contacts: FormattedContact[] = []
const groupContacts = dbRef.getAllGroupContacts() const groupContacts = dbRef.getAllGroupContacts()
const userList = dbRef.getUserList(filter) const userList = dbRef.getUserList()
const existingMd5s = new Set<string>() const existingMd5s = new Set<string>()
for (const user of userList) { for (const user of userList) {
@@ -192,6 +272,10 @@ export function listContacts(filter?: string): FormattedContact[] {
avatar: typeof user.avatar === 'string' ? user.avatar : undefined, avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
wechatNickname: user.wechatNickname, wechatNickname: user.wechatNickname,
remark: user.remark, remark: user.remark,
alias: user.alias,
wechatId: user.wechatId,
wxid: user.wxid || user.m_nsUsrName,
legacyIdentifier: user.legacyIdentifier,
isFolded: user.isFolded, isFolded: user.isFolded,
isMuted: user.isMuted isMuted: user.isMuted
}) })
@@ -222,7 +306,30 @@ export function listContacts(filter?: string): FormattedContact[] {
} }
} }
} }
return contacts if (!filter) return contacts
const signature = contacts
.map((contact) =>
[
contact.md5,
contact.m_nsNickName,
contact.remark,
contact.wechatNickname,
contact.alias,
contact.wechatId,
contact.wxid,
contact.legacyIdentifier,
contact.avatar,
contact.isFolded,
contact.isMuted
]
.map((value) => String(value || ''))
.join('\u0001')
)
.join('\u0002')
if (!contactSearchIndexCache || contactSearchIndexCache.signature !== signature) {
contactSearchIndexCache = { signature, index: buildContactSearchIndex(contacts) }
}
return filterContactSearchIndex(contactSearchIndexCache.index, filter)
} }
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> { export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
@@ -233,18 +340,38 @@ export async function listContactsAsync(filter?: string): Promise<FormattedConta
hydrateDisplayNames: true, hydrateDisplayNames: true,
hydrateStatuses: true hydrateStatuses: true
}) })
await dbRef.hydrateContactIdentitiesAsync?.()
return listContacts(filter) return listContacts(filter)
} }
export async function getContactAvatars(usernames: string[]): Promise<Record<string, string>> { /** 读取已缓存/轻量 Session 群名,不执行成员、头像或联系人资料 hydration。 */
export async function getGroupNamesAsync(): Promise<Record<string, string>> {
if (!dbRef) return {}
const sessions = await dbRef.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: false })
const names: Record<string, string> = {}
for (const session of sessions) {
if (!session.username.endsWith('@chatroom')) continue
const name = String(session.nickname || '').trim()
if (name) names[session.username] = name
}
return names
}
export async function getContactAvatars(
usernames: string[],
options?: { refresh?: boolean }
): Promise<Record<string, string>> {
if (!dbRef) return {} if (!dbRef) return {}
const normalized = Array.from( const normalized = Array.from(
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean)) new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
) )
if (normalized.length === 0) return {} if (normalized.length === 0) return {}
return dbRef.getWcdb4Client().getAvatarUrlsAsync(normalized) const client = dbRef.getWcdb4Client()
if (options?.refresh) client.invalidateAvatarCache(normalized)
return client.getAvatarUrlsAsync(normalized)
} }
/** Format source rows and register image handles without merging recall archives. */
function listSourceMessages( function listSourceMessages(
userMd5: string, userMd5: string,
startTime?: number, startTime?: number,
@@ -434,11 +561,28 @@ function listSourceMessages(
: msg.mesLocalID || Math.random().toString() : msg.mesLocalID || Math.random().toString()
) )
const imageContent = contentData?.type === 'image' ? contentData : undefined const imageContent = contentData?.type === 'image' ? contentData : undefined
// Local ids repeat across conversations. Scope media handles to this database
// connection and image without changing the message id used by other clients.
const mediaId = imageContent
? `image:${createHash('sha256')
.update(
JSON.stringify([
imageReferenceScope,
userMd5,
messageId,
String(msg.serverId || ''),
createTime,
imageContent.md5 || '',
imageContent.datName || ''
])
)
.digest('hex')}`
: ''
const media = imageContent const media = imageContent
? { ? {
type: 'image' as const, type: 'image' as const,
available: Boolean(imageContent.md5 || imageContent.datName), available: Boolean(imageContent.md5 || imageContent.datName),
url: `/api/v1/media/${encodeURIComponent(messageId)}` url: `/api/v1/media/${encodeURIComponent(mediaId)}`
} }
: undefined : undefined
if (imageContent && media) { if (imageContent && media) {
@@ -449,6 +593,8 @@ function listSourceMessages(
imageDatName: imageContent.datName, imageDatName: imageContent.datName,
createTime createTime
} }
imageMessageReferences.set(mediaId, reference)
// Keep old bare-id URLs working only while they are unambiguous.
const previous = imageMessageReferences.get(messageId) const previous = imageMessageReferences.get(messageId)
if ( if (
previous && previous &&
@@ -476,7 +622,10 @@ function listSourceMessages(
senderId, senderId,
sessionId: username, sessionId: username,
localId, localId,
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined, serverId:
typeof msg.serverId === 'string' || typeof msg.serverId === 'bigint'
? String(msg.serverId)
: undefined,
createTime, createTime,
recoveredFromRecallJournal, recoveredFromRecallJournal,
contentData, contentData,
@@ -583,8 +732,8 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
const roomId = wcdb4Client.getUsernameByMd5(userMd5) const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client const rawMembers = wcdb4Client.getGroupMembers(roomId)
.getGroupMembers(roomId) const members = (Array.isArray(rawMembers) ? rawMembers : [])
.filter((member) => member?.m_nsUsrName) .filter((member) => member?.m_nsUsrName)
.map((member) => ({ .map((member) => ({
wxid: member.m_nsUsrName, wxid: member.m_nsUsrName,
@@ -604,7 +753,8 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
const roomId = wcdb4Client.getUsernameByMd5(userMd5) const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = (await wcdb4Client.getGroupMembersAsync(roomId)) const rawMembers = await wcdb4Client.getGroupMembersAsync(roomId)
const members = (Array.isArray(rawMembers) ? rawMembers : [])
.filter((member) => member?.m_nsUsrName) .filter((member) => member?.m_nsUsrName)
.map((member) => ({ .map((member) => ({
wxid: member.m_nsUsrName, wxid: member.m_nsUsrName,
@@ -614,7 +764,69 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
remark: member.remark || '', remark: member.remark || '',
avatar: member.m_nsHeadImgUrl || '' avatar: member.m_nsHeadImgUrl || ''
})) }))
return { roomId, memberCount: members.length, members } const session = wcdb4Client.getSessions().find((item) => item.username === roomId)
return {
roomId,
groupName: session?.nickname || undefined,
memberCount: members.length,
members
}
}
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
export async function getGroupMemberIdsAsync(
roomId: string
): Promise<GroupMembershipSnapshot | null> {
if (!dbRef || !roomId.endsWith('@chatroom')) return null
const memberIds = await dbRef.getWcdb4Client().getGroupMemberIdsAsync(roomId)
return memberIds ? { roomId, memberIds } : null
}
/**
* Evidence sender enrichment 专用:只解析**请求到的** wxid 的显示名。
*
* **不**走 `getGroupSnapshotAsync` —— 后者会 materialize 整群成员并 hydrate 头像,
* 为拿 1~N 个名字付整群成本。返回结构故意与 `GroupSnapshot['members']` 一致,
* 这样调用方可以复用同一套显示名优先级规则,不会把「张三」退化成「wxid_xxx」。
* `avatar` 恒为 `''`:头像若将来需要,走 lazy UI 路径,不进入 Query Tool 成本。
*/
export async function getGroupMemberNamesAsync(
userMd5: string,
wxids: string[]
): Promise<GroupSnapshot['members']> {
if (!dbRef) return []
const requested = Array.from(new Set((wxids || []).filter(Boolean)))
if (requested.length === 0) return []
const wcdb4Client = dbRef.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return []
const members = await wcdb4Client.getGroupMemberNamesAsync(roomId, requested)
return members.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
groupNickname: member.groupNickname || '',
wechatNickname: member.wechatNickname || '',
remark: member.remark || '',
avatar: member.m_nsHeadImgUrl || ''
}))
}
export function isGroupMemberIdsBatchAvailable(): boolean {
return Boolean(dbRef?.getWcdb4Client().isGroupMemberIdsBatchAvailable())
}
export async function getGroupMemberIdsBatchAsync(
roomIds: string[]
): Promise<GroupMembershipBatchSnapshot[] | null> {
if (!dbRef) return null
const results = await dbRef.getWcdb4Client().getGroupMemberIdsBatchAsync(roomIds)
return results
? results.map((result) => ({
roomId: result.roomId,
status: result.status,
memberIds: result.memberWxids
}))
: null
} }
export function searchMessages(keyword: string): string | null { export function searchMessages(keyword: string): string | null {
@@ -637,13 +849,18 @@ export function resolveMd5(query: string): FormattedContact | null {
(c) => (c) =>
c.md5 === trimmed || c.md5 === trimmed ||
c.m_nsUsrName.toLowerCase() === lower || c.m_nsUsrName.toLowerCase() === lower ||
c.wechatId?.toLowerCase() === lower ||
c.wxid?.toLowerCase() === lower ||
c.m_nsNickName.toLowerCase() === lower c.m_nsNickName.toLowerCase() === lower
) )
if (exact) return exact if (exact) return exact
const partial = contacts.find( const partial = contacts.find(
(c) => (c) =>
c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower) c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower) ||
c.wechatId?.toLowerCase().includes(lower) ||
c.wxid?.toLowerCase().includes(lower)
) )
return partial || null return partial || null
} }
@@ -22,7 +22,8 @@ const aliases = (contact: Contact): Array<{ value: string; primary: boolean }> =
{ value: contact.m_nsNickName, primary: true }, { value: contact.m_nsNickName, primary: true },
{ value: contact.remark || '', primary: false }, { value: contact.remark || '', primary: false },
{ value: contact.wechatNickname || '', primary: false }, { value: contact.wechatNickname || '', primary: false },
{ value: contact.m_nsUsrName, primary: false }, { value: contact.wechatId || contact.alias || '', primary: false },
{ value: contact.wxid || contact.m_nsUsrName, primary: false },
...safeGroupAlias ...safeGroupAlias
].filter((item) => Boolean(normalizeContactName(item.value))) ].filter((item) => Boolean(normalizeContactName(item.value)))
} }
@@ -81,7 +82,7 @@ export function resolveContact(
const contact = matches.get(candidate.conversationId)!.contact const contact = matches.get(candidate.conversationId)!.contact
return { return {
matched: true, matched: true,
personId: contact.m_nsUsrName, personId: contact.wxid || contact.m_nsUsrName,
conversationId: contact.md5, conversationId: contact.md5,
canonicalName: displayName(contact), canonicalName: displayName(contact),
displayName: candidate.displayName, displayName: candidate.displayName,
@@ -0,0 +1,978 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import * as chat from './chat-service'
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
import {
buildMemberLeftNotification,
findRemovedGroupMembers,
groupExitMemberName,
normalizeGroupExitNotificationTemplate,
validateGroupExitNotificationTemplate,
type GroupExitMonitorEvent,
type GroupExitMonitorMember,
type GroupExitNotificationStatus,
type GroupExitNotificationState,
type GroupExitMonitorState
} from '../../shared/group-exit-monitor'
type StoredState = {
enabled?: boolean
accountRoot?: string
events?: Partial<GroupExitMonitorEvent>[]
lastReadAt?: number
monitorSelectionConfigured?: boolean
monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: unknown
snapshots?: Partial<StoredGroupSnapshot>[]
}
type GroupSnapshotRecord = {
contactId: string
roomId: string
groupName: string
capturedAt: number
members: GroupExitMonitorMember[]
/** 查询失败时保留旧快照。 */
membersValid?: boolean
}
type GroupMembershipRecord = Omit<GroupSnapshotRecord, 'members'> & {
memberIds: string[]
}
type MembershipMode = 'batch' | 'legacy'
type MembershipReadResult = {
mode: MembershipMode
groupCount: number
groups: GroupMembershipRecord[] | null
}
type StoredGroupSnapshot = Pick<
GroupSnapshotRecord,
'contactId' | 'roomId' | 'groupName' | 'capturedAt' | 'members'
>
const DB_CHANGE_DEBOUNCE_MS = 350
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
const MAX_EVENTS = 500
export interface GroupExitMonitorServiceDependencies {
actionGateway?: GroupExitActionGateway
}
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
Partial<
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
>
class GroupExitMonitorService {
private readonly actionGateway: GroupExitActionGateway
private active = false
private enabled = true
private nativeMonitorActive = false
private snapshots = new Map<string, GroupSnapshotRecord>()
private changeTimer: NodeJS.Timeout | null = null
private checking = false
private checkQueued = false
private initializing = false
private hydrating = false
private hydrationQueue = new Set<string>()
private hydrationBatchStartedAt: number | null = null
private hydrationBatchGroups = 0
private lastCheckedAt: number | undefined
private lastReadAt = 0
private events: GroupExitMonitorEvent[] = []
private monitorSelectionConfigured = true
private monitoredRoomIds = new Set<string>()
private notificationRoomIds = new Set<string>()
private loaded = false
private accountRoot = ''
private groupNamesByRoomId = new Map<string, string>()
private groupNamesRefreshPending = true
private eventSequence = 0
private scopeGeneration = 0
private legacyFallbackLogged = false
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
this.actionGateway = deps.actionGateway || wechatActionGateway
}
getState(): GroupExitMonitorState {
this.ensureLoaded()
return {
events: [...this.events],
enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive,
monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
lastCheckedAt: this.lastCheckedAt,
lastReadAt: this.lastReadAt,
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
}
}
async start(nativeMonitorActive: boolean): Promise<void> {
this.ensureLoaded()
const currentRoot = chat.getCurrentAccountRoot()
if (!currentRoot) {
this.active = true
this.nativeMonitorActive = nativeMonitorActive
this.broadcast()
return
}
if (
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
(!this.accountRoot && this.snapshots.size > 0)
) {
this.actionGateway.clearMemberEvents?.()
this.events = []
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.snapshots.clear()
this.groupNamesByRoomId.clear()
}
this.accountRoot = currentRoot
this.groupNamesRefreshPending = true
this.active = true
this.nativeMonitorActive = nativeMonitorActive
this.checkQueued = false
if (!this.enabled) {
this.save()
this.broadcast()
return
}
this.initializing = true
const scopeGeneration = ++this.scopeGeneration
this.eventSequence += 1
try {
const checked = await this.runMembershipCheck(scopeGeneration)
if (!this.active || this.scopeGeneration !== scopeGeneration) return
if (!checked) {
this.save()
this.broadcast()
}
} finally {
this.initializing = false
if (this.checkQueued && this.active) {
this.checkQueued = false
void this.check()
} else {
this.startNextSnapshotHydration()
}
}
}
stop(): void {
this.active = false
this.nativeMonitorActive = false
this.eventSequence += 1
this.scopeGeneration += 1
this.checkQueued = false
this.hydrationQueue.clear()
this.groupNamesRefreshPending = true
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
this.broadcast()
}
notifyDatabaseChanged(rawPayload: string): void {
if (!this.enabled || !this.active || !isContactEvent(rawPayload)) return
try {
chat.getChatDb()?.getWcdb4Client().invalidateGroupNicknameCache()
} catch {
// 缓存清理失败时继续检查。
}
this.groupNamesRefreshPending = true
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = setTimeout(() => {
this.changeTimer = null
void this.check()
}, DB_CHANGE_DEBOUNCE_MS)
}
async checkNow(): Promise<GroupExitMonitorState> {
this.ensureLoaded()
if (!this.enabled) return this.getState()
if (!this.active && chat.isReady()) await this.start(false)
await this.check()
return this.getState()
}
async setMonitoredRoomIds(
roomIds: string[],
notificationRoomIds: string[] = []
): Promise<GroupExitMonitorState> {
this.ensureLoaded()
this.eventSequence += 1
this.scopeGeneration += 1
this.monitorSelectionConfigured = true
const nextMonitoredRoomIds = normalizeRoomIds(roomIds)
for (const roomId of this.snapshots.keys()) {
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
}
for (const roomId of this.hydrationQueue) {
if (!nextMonitoredRoomIds.has(roomId)) this.hydrationQueue.delete(roomId)
}
this.monitoredRoomIds = nextMonitoredRoomIds
this.groupNamesRefreshPending = true
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
this.notificationRoomIds = new Set(
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
)
this.lastCheckedAt = undefined
this.save()
this.broadcast()
// 建立基线放到后台,保存配置可以立即返回。
if (this.enabled && this.active) void this.check()
return this.getState()
}
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
this.ensureLoaded()
if (this.enabled === enabled) return this.getState()
this.enabled = enabled
this.eventSequence += 1
this.scopeGeneration += 1
this.checkQueued = false
this.hydrationQueue.clear()
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
if (enabled) {
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
this.snapshots.clear()
this.lastCheckedAt = undefined
this.groupNamesRefreshPending = true
}
this.save()
this.broadcast()
if (enabled && this.active && chat.isReady()) await this.check()
return this.getState()
}
setNotificationTemplate(value: unknown): GroupExitMonitorState {
this.ensureLoaded()
const result = validateGroupExitNotificationTemplate(value)
if (!result.valid || !result.template) {
throw new Error(result.error || '退群监测模板无效')
}
this.notificationTemplate = result.template
this.save()
this.broadcast()
return this.getState()
}
clearEvents(): GroupExitMonitorState {
this.ensureLoaded()
this.events = []
this.actionGateway.clearMemberEvents?.()
this.lastReadAt = Date.now()
this.save()
this.broadcast()
return this.getState()
}
markRead(readAt?: number): GroupExitMonitorState {
this.ensureLoaded()
this.lastReadAt = Math.max(this.lastReadAt, Number(readAt) || Date.now())
this.save()
this.broadcast()
return this.getState()
}
private async check(): Promise<void> {
const currentRoot = chat.getCurrentAccountRoot()
if (
!this.active ||
!this.enabled ||
!chat.isReady() ||
!currentRoot ||
!this.accountRoot ||
!sameAccountRoot(currentRoot, this.accountRoot)
)
return
if (this.initializing || this.hydrating) {
this.checkQueued = true
return
}
if (this.checking) {
this.checkQueued = true
return
}
this.checking = true
const scopeGeneration = this.scopeGeneration
try {
await this.runMembershipCheck(scopeGeneration)
} finally {
this.checking = false
if (this.checkQueued && this.active) {
this.checkQueued = false
void this.check()
} else {
this.startNextSnapshotHydration()
}
}
}
private async runMembershipCheck(scopeGeneration: number): Promise<boolean> {
const startedAt = Date.now()
const result = await this.readMemberships()
const membershipCostMs = Date.now() - startedAt
let changedGroups = 0
if (result.groups && this.enabled && this.active && scopeGeneration === this.scopeGeneration) {
changedGroups = await this.applyCurrentMemberships(result.groups, scopeGeneration)
}
console.log(
`[GroupMonitor] check mode=${result.mode} groups=${result.groupCount} membershipCostMs=${membershipCostMs} changedGroups=${changedGroups} totalCostMs=${Date.now() - startedAt}`
)
return result.groups !== null
}
private async readMemberships(): Promise<MembershipReadResult> {
await this.refreshGroupNamesIfNeeded()
const database = chat.getChatDb()
const roomIds = Array.from(this.monitoredRoomIds)
const batchAvailable = chat.isGroupMemberIdsBatchAvailable()
const mode: MembershipMode = batchAvailable ? 'batch' : 'legacy'
if (!database) return { mode, groupCount: roomIds.length, groups: null }
try {
const client = database.getWcdb4Client()
const capturedAt = Date.now()
if (batchAvailable) {
const snapshots = await chat.getGroupMemberIdsBatchAsync(roomIds)
if (!snapshots) return { mode, groupCount: roomIds.length, groups: null }
return {
mode,
groupCount: roomIds.length,
groups: snapshots.map((snapshot) => {
const previous = this.snapshots.get(snapshot.roomId)
return {
contactId: previous?.contactId || client.md5(snapshot.roomId),
roomId: snapshot.roomId,
groupName: resolveGroupName(snapshot.roomId, this.groupNamesByRoomId, previous),
capturedAt,
memberIds: normalizeMemberIds(snapshot.memberIds),
membersValid: snapshot.status === 'ok'
}
})
}
}
if (!this.legacyFallbackLogged) {
this.legacyFallbackLogged = true
console.warn('[GroupMonitor] batch membership unavailable; using legacy fallback')
}
const groups: GroupMembershipRecord[] = []
for (const roomId of roomIds) {
const previous = this.snapshots.get(roomId)
const snapshot = await chat.getGroupMemberIdsAsync(roomId)
groups.push({
contactId: previous?.contactId || client.md5(roomId),
roomId,
groupName: resolveGroupName(roomId, this.groupNamesByRoomId, previous),
capturedAt,
memberIds: snapshot?.roomId === roomId ? normalizeMemberIds(snapshot.memberIds) : [],
membersValid: snapshot?.roomId === roomId
})
}
return { mode, groupCount: roomIds.length, groups }
} catch (error) {
console.warn('[GroupMonitor] 读取群成员状态失败:', error)
return { mode, groupCount: roomIds.length, groups: null }
}
}
private async applyCurrentMemberships(
groups: GroupMembershipRecord[],
scopeGeneration: number
): Promise<number> {
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
let changedGroups = 0
for (const membership of groups) {
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
return changedGroups
}
if (membership.membersValid === false) continue
const previous = this.snapshots.get(membership.roomId)
// 空数组可能是查询失败,先保留旧基线,避免误报和覆盖最后有效快照。
if (previous && previous.members.length > 0 && membership.memberIds.length === 0) continue
const previousMembers = new Map(previous?.members.map((member) => [member.wxid, member]))
const next: GroupSnapshotRecord = {
contactId: membership.contactId,
roomId: membership.roomId,
groupName:
membership.groupName === membership.roomId && previous
? previous.groupName
: membership.groupName,
capturedAt: membership.capturedAt,
members: membership.memberIds.map(
(wxid) => previousMembers.get(wxid) || ({ wxid } satisfies GroupExitMonitorMember)
),
membersValid: true
}
const membershipChanged = !previous || !sameMemberIds(previous.members, membership.memberIds)
if (membershipChanged) changedGroups += 1
if (previous && next.members.length < previous.members.length) {
const removed = findRemovedGroupMembers(previous.members, next.members)
for (const member of removed) {
const event = this.recordExit(next, member, previous.members.length, next.members.length)
if (event && this.notificationRoomIds.has(next.roomId)) {
notifications.push({ group: next, event })
}
}
}
// Last Good Snapshot 必须在 Diff 完成后才能替换。
this.snapshots.set(next.roomId, next)
if (next.members.some((member) => !hasMemberMetadata(member))) {
this.hydrationQueue.add(next.roomId)
}
}
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
return changedGroups
}
this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
this.save()
this.broadcast()
if (notifications.length) {
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
}
return changedGroups
}
private startNextSnapshotHydration(): void {
if (!this.enabled || !this.active) {
this.finishHydrationBatch()
return
}
if (this.initializing || this.checking || this.hydrating) return
if (this.checkQueued || this.changeTimer) {
this.finishHydrationBatch()
return
}
const roomId = this.hydrationQueue.values().next().value as string | undefined
if (!roomId) {
this.finishHydrationBatch()
return
}
this.hydrationQueue.delete(roomId)
const baseline = this.snapshots.get(roomId)
if (!baseline || !this.monitoredRoomIds.has(roomId)) {
this.startNextSnapshotHydration()
return
}
this.hydrating = true
if (this.hydrationBatchStartedAt === null) this.hydrationBatchStartedAt = Date.now()
this.hydrationBatchGroups += 1
const scopeGeneration = this.scopeGeneration
void chat
.getGroupSnapshotAsync(baseline.contactId)
.then((snapshot) => {
const current = this.snapshots.get(roomId)
if (
!snapshot ||
snapshot.roomId !== roomId ||
!this.active ||
scopeGeneration !== this.scopeGeneration ||
!current ||
!sameMemberIds(
current.members,
snapshot.members.map((member) => member.wxid)
)
)
return
const hydrated = new Map(
normalizeSnapshotMembers(snapshot.members).map((member) => [member.wxid, member])
)
this.snapshots.set(roomId, {
...current,
groupName: resolveGroupName(roomId, this.groupNamesByRoomId, current, snapshot.groupName),
members: current.members.map((member) => {
const refreshed = hydrated.get(member.wxid)
return refreshed ? { ...member, ...refreshed } : member
})
})
this.hydrationQueue.delete(roomId)
this.save()
this.broadcast()
})
.catch(() => undefined)
.finally(() => {
this.hydrating = false
if (this.checkQueued && this.active) {
this.finishHydrationBatch()
this.checkQueued = false
void this.check()
} else if (this.changeTimer) {
this.finishHydrationBatch()
} else {
this.startNextSnapshotHydration()
}
})
}
private finishHydrationBatch(): void {
if (this.hydrationBatchStartedAt === null || this.hydrationBatchGroups === 0) return
console.log(
`[GroupMonitor] hydration groups=${this.hydrationBatchGroups} costMs=${Date.now() - this.hydrationBatchStartedAt}`
)
this.hydrationBatchStartedAt = null
this.hydrationBatchGroups = 0
}
private async refreshGroupNamesIfNeeded(): Promise<void> {
if (!this.groupNamesRefreshPending) return
this.groupNamesRefreshPending = false
try {
const names = await chat.getGroupNamesAsync()
for (const [roomId, name] of Object.entries(names || {})) {
const resolved = normalizeKnownGroupName(roomId, name)
if (resolved) this.groupNamesByRoomId.set(roomId, resolved)
}
} catch {
// Session 群名是展示信息;读取失败时继续使用快照中的已知群名。
}
}
private recordExit(
group: GroupSnapshotRecord,
member: GroupExitMonitorMember,
previousCount: number,
currentCount: number
): GroupExitMonitorEvent | null {
const memberName = groupExitMemberName(member)
const wechatName = String(member.wechatNickname || '').trim()
const groupRemark = String(member.groupNickname || '').trim()
const contactRemark = String(member.remark || '').trim()
const detectedAt = Date.now()
const message = `${memberName}退出了${group.groupName}`
const duplicate = this.events.some(
(event) =>
event.roomId === group.roomId &&
event.memberWxid === member.wxid &&
event.previousCount === previousCount &&
event.currentCount === currentCount &&
Math.abs(event.detectedAt - detectedAt) < DUPLICATE_WINDOW_MS
)
if (duplicate) return null
const event: GroupExitMonitorEvent = {
id: `${group.roomId}:${member.wxid}:${detectedAt}:${this.eventSequence++}`,
contactId: group.contactId,
roomId: group.roomId,
groupName: group.groupName,
memberWxid: member.wxid,
memberName,
wechatName,
groupRemark,
contactRemark,
previousCount,
currentCount,
delta: currentCount - previousCount,
message,
detectedAt,
notificationStatus: 'not_requested'
}
this.actionGateway.registerMemberEvent?.(event)
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
console.log(
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
)
return event
}
private async notifyGroup(
group: GroupSnapshotRecord,
event: GroupExitMonitorEvent,
idempotencyKey = `member_left_notification:${event.id}`
): Promise<void> {
event.notificationStatus = 'pending'
event.notification = { status: 'pending' }
this.save()
this.broadcast()
try {
const result = await this.actionGateway.execute({
idempotencyKey,
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: event.id,
recipient: {
type: 'group',
id: group.roomId,
name: group.groupName
},
content: {
type: 'text',
text: buildMemberLeftNotification(event, this.notificationTemplate)
},
metadata: {
memberId: event.memberWxid,
memberName: event.memberName,
detectedAt: event.detectedAt,
eventType: 'member_left',
eventRoomId: event.roomId
}
})
const notification: GroupExitNotificationState = {
status: result.status,
actionId: result.actionId,
decision: result.decision,
...(result.errorCode ? { errorCode: result.errorCode } : {}),
...(result.reason ? { reason: result.reason } : {}),
startedAt: result.startedAt,
finishedAt: result.finishedAt
}
event.notificationStatus = result.status
event.notification = notification
if (result.status !== 'sent') {
console.warn(
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
)
}
} catch (error) {
event.notificationStatus = 'failed'
event.notification = {
status: 'failed',
errorCode: 'UNKNOWN',
reason: error instanceof Error ? error.message : String(error)
}
console.warn(
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
error instanceof Error ? error.message : String(error)
)
} finally {
this.save()
this.broadcast()
}
}
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
const event = this.events.find((item) => item.id === eventId)
if (!event) throw new Error('退群动态不存在')
await this.notifyGroup(
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
event,
`member_left_notification:${event.id}:retry:${Date.now()}`
)
return this.getState()
}
private filePath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const stored = fs.readJsonSync(this.filePath()) as StoredState
this.enabled = stored.enabled !== false
this.events = normalizeEvents(stored.events)
this.actionGateway.registerMemberEvents?.(this.events)
this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '')
// 没有显式范围时按空范围处理,保留已有选择。
this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
this.notificationRoomIds = new Set(
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
this.monitoredRoomIds.has(roomId)
)
)
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
stored.notificationTemplate
)
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
} catch {
// 首次启动或文件损坏时从空记录开始。
this.events = []
this.enabled = true
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
this.snapshots.clear()
}
}
private save(): void {
this.ensureLoaded()
try {
fs.ensureDirSync(path.dirname(this.filePath()))
fs.writeJsonSync(
this.filePath(),
{
accountRoot: this.accountRoot,
enabled: this.enabled,
events: this.events,
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
},
{ spaces: 2 }
)
} catch (error) {
console.warn('[GroupMonitor] 保存状态失败:', error)
}
}
private broadcast(): void {
const state = this.getState()
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('group-exit-monitor:state', state)
}
}
}
function normalizeRoomIds(roomIds: string[]): Set<string> {
return new Set(
(Array.isArray(roomIds) ? roomIds : [])
.map((roomId) => String(roomId || '').trim())
.filter((roomId) => roomId.endsWith('@chatroom'))
)
}
function normalizeMemberIds(values: unknown[]): string[] {
return Array.from(
new Set(values.map((value) => String(value || '').trim()).filter((value) => Boolean(value)))
)
}
function sameMemberIds(previous: GroupExitMonitorMember[], nextIds: string[]): boolean {
if (previous.length !== nextIds.length) return false
const previousIds = new Set(previous.map((member) => member.wxid))
return nextIds.every((wxid) => previousIds.has(wxid))
}
function hasMemberMetadata(member: GroupExitMonitorMember): boolean {
return Boolean(
member.nickname?.trim() ||
member.groupNickname?.trim() ||
member.wechatNickname?.trim() ||
member.remark?.trim()
)
}
function sameAccountRoot(left: string, right: string): boolean {
const normalize = (value: string): string => {
const resolved = path.resolve(value)
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
}
return normalize(left) === normalize(right)
}
function normalizeSnapshots(
values: Partial<StoredGroupSnapshot>[] | undefined,
monitoredRoomIds: Set<string>
): Map<string, GroupSnapshotRecord> {
const snapshots = new Map<string, GroupSnapshotRecord>()
if (!Array.isArray(values)) return snapshots
for (const value of values) {
const roomId = String(value?.roomId || '').trim()
const capturedAt = Number(value?.capturedAt)
if (
!roomId.endsWith('@chatroom') ||
!monitoredRoomIds.has(roomId) ||
!Number.isFinite(capturedAt) ||
capturedAt <= 0 ||
!Array.isArray(value?.members)
) {
continue
}
snapshots.set(roomId, {
contactId: String(value.contactId || ''),
roomId,
groupName: cleanGroupName(value.groupName, roomId),
capturedAt,
members: normalizeSnapshotMembers(value.members)
})
}
return snapshots
}
function normalizeSnapshotMembers(values: GroupExitMonitorMember[]): GroupExitMonitorMember[] {
const members: GroupExitMonitorMember[] = []
const seen = new Set<string>()
for (const value of values) {
const wxid = String(value?.wxid || '').trim()
if (!wxid || seen.has(wxid)) continue
seen.add(wxid)
members.push({
wxid,
...optionalMemberField('nickname', value.nickname),
...optionalMemberField('groupNickname', value.groupNickname),
...optionalMemberField('wechatNickname', value.wechatNickname),
...optionalMemberField('remark', value.remark),
...optionalMemberField('avatar', value.avatar)
})
}
return members
}
function optionalMemberField<K extends keyof GroupExitMonitorMember>(
key: K,
value: unknown
): Partial<Pick<GroupExitMonitorMember, K>> {
const normalized = String(value || '').trim()
return normalized ? ({ [key]: normalized } as Pick<GroupExitMonitorMember, K>) : {}
}
function normalizeKnownGroupName(roomId: string, value: unknown): string {
const name = String(value || '').trim()
return name && name !== roomId && !name.endsWith('@chatroom') ? name : ''
}
function resolveGroupName(
roomId: string,
namesByRoomId: Map<string, string>,
previous?: Pick<GroupSnapshotRecord, 'groupName'>,
hydratedName?: unknown
): string {
return (
normalizeKnownGroupName(roomId, namesByRoomId.get(roomId)) ||
normalizeKnownGroupName(roomId, previous?.groupName) ||
normalizeKnownGroupName(roomId, hydratedName) ||
roomId
)
}
function toStoredSnapshot(snapshot: GroupSnapshotRecord): StoredGroupSnapshot {
return {
contactId: snapshot.contactId,
roomId: snapshot.roomId,
groupName: snapshot.groupName,
capturedAt: snapshot.capturedAt,
members: normalizeSnapshotMembers(snapshot.members)
}
}
function cleanGroupName(value: string | undefined, roomId: string): string {
const name = String(value || '').trim()
return name && name !== roomId && !name.startsWith('wxid_') ? name : roomId
}
function normalizeEvents(
values: Partial<GroupExitMonitorEvent>[] | undefined
): GroupExitMonitorEvent[] {
if (!Array.isArray(values)) return []
const normalized: GroupExitMonitorEvent[] = []
for (const value of values) {
const memberName = String(value.memberName || '').trim()
const groupName = String(value.groupName || '').trim()
const roomId = String(value.roomId || '').trim()
const previousCount = Number(value.previousCount)
const currentCount = Number(value.currentCount)
const detectedAt = Number(value.detectedAt)
// 旧记录没有人数变化,无法确认退群,直接忽略。
if (
!memberName ||
!groupName ||
!roomId ||
!Number.isFinite(previousCount) ||
!Number.isFinite(currentCount) ||
previousCount <= currentCount ||
previousCount <= 0 ||
!Number.isFinite(detectedAt) ||
detectedAt <= 0
) {
continue
}
const memberWxid = String(value.memberWxid || '').trim()
const duplicate = normalized.some(
(event) =>
event.roomId === roomId &&
event.memberWxid === memberWxid &&
event.previousCount === previousCount &&
event.currentCount === currentCount &&
Math.abs(event.detectedAt - detectedAt) < DUPLICATE_WINDOW_MS
)
if (duplicate) continue
normalized.push({
id: String(value.id || `${roomId}:${memberWxid || memberName}:${detectedAt}`),
contactId: String(value.contactId || ''),
roomId,
groupName,
memberWxid,
memberName,
wechatName: String(value.wechatName || '').trim(),
groupRemark: String(value.groupRemark || '').trim(),
contactRemark: String(value.contactRemark || '').trim(),
previousCount,
currentCount,
delta: Number.isFinite(Number(value.delta))
? Number(value.delta)
: currentCount - previousCount,
message: String(value.message || `${memberName}退出了${groupName}`),
detectedAt,
...(value.notificationStatus
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
: {}),
...(value.notification && typeof value.notification === 'object'
? { notification: normalizeNotification(value.notification) }
: {})
})
if (normalized.length >= MAX_EVENTS) break
}
return normalized
}
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
const status = String(value || '').trim()
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
? status
: 'not_requested'
}
function normalizeNotification(value: object): GroupExitNotificationState {
const input = value as Partial<GroupExitNotificationState>
return {
status: normalizeNotificationStatus(input.status),
...(input.actionId ? { actionId: String(input.actionId) } : {}),
...(input.decision === 'allow' || input.decision === 'block'
? { decision: input.decision }
: {}),
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
...(input.reason ? { reason: String(input.reason) } : {}),
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
}
}
function isContactEvent(rawPayload: string): boolean {
const payload = String(rawPayload || '').trim()
if (!payload) return false
try {
const parsed = JSON.parse(payload) as { table?: unknown }
return String(parsed.table || '').toLowerCase() === 'contact'
} catch {
return /["']table["']\s*:\s*["']contact["']/i.test(payload)
}
}
export const groupExitMonitorService = new GroupExitMonitorService()
export { GroupExitMonitorService }
@@ -0,0 +1,655 @@
import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, type FormattedMessage } from './chat-service'
import { resolveContact } from './contact-resolution-service'
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { inferAiSearchTimeRange } from '../../shared/ai-search'
import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge'
import type {
QueryCapabilitiesResponse,
QueryCorpusScope,
QueryEvidenceItem,
QueryMessage,
QueryMessageType,
QueryTimeRange,
ResolvedCorpusScope,
ResolvedTimeRange,
QueryIndexCoverage,
QuerySearchTimings,
QueryMessagesRequest,
SearchMessagesRequest,
MessageContextRequest,
ConversationOverviewRequest
} from '../../shared/local-query-api'
// messageRef 编解码是 main 与 renderer 共用的契约,只定义一次(`src/shared/local-query-api.ts`):
// 两侧各写一份 base64url 实现会悄悄漂移,那会让「跳转到原聊天」偶发失效。
import {
encodeMessageRef as toRef,
decodeMessageRef as fromRef,
normalizeMessageIdentity
} from '../../shared/local-query-api'
const LIMIT_MAX = 200
const CONTEXT_MAX = 50
/** 会话概览直读源数据时的上限(与派生索引的会话概览同量级)。 */
const OVERVIEW_SOURCE_CAP = 2000
/** 会话概览最多挑选多少条代表证据(与派生索引的候选上限一致)。 */
const OVERVIEW_EVIDENCE_TARGET = 60
const OVERVIEW_CHUNK_GAP_MS = 2 * 60 * 60 * 1000
const OVERVIEW_CHUNK_MAX_MESSAGES = 24
const kinds: QueryMessageType[] = ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other']
/**
* 查询触发追赶同步后最多等待多久(`QUERY_FRESHNESS_WAIT_BUDGET`)。
*
* 一次完整 pass 的耗时以分钟计,远超任何交互预算。所以这个预算只用来兜住「已经很接近追平」
* 的情况:追不上就按当前覆盖如实回答并让后台继续追,而不是把查询卡在索引上。
*/
const QUERY_FRESHNESS_WAIT_BUDGET_MS = 2000
/**
* 两次由查询触发的追赶之间的最小间隔,避免每个 Query 都重跑一遍索引。
*/
const QUERY_CATCH_UP_MIN_INTERVAL_MS = 30 * 1000
/**
* 值得为它跑一遍索引的最小落后量(下限)。
*
* 追赶一遍的成本以分钟计,因此几秒钟/一两分钟的落后并不值得触发。真正的门槛是
* `max(这个下限, 上一遍实际耗时)` —— 见 `LocalQueryApiService.ensureFreshness`。
* 这样在"源数据一直在长"的情况下会自然收敛:一遍跑完后剩下的落后量约等于这一遍的耗时,
* 于是不会立刻再触发一遍(否则会变成永不停止的连续索引)。
*/
const QUERY_CATCH_UP_MIN_LAG_MS = 2 * 60 * 1000
/**
* 请求的时间范围是否已经被派生索引覆盖。
*
* 需要覆盖的真实边界是 `min(requestedEnd, sourceLatestAt)`:
* - 请求范围早于源数据最新时间 → 必须覆盖到 requestedEnd;
* - 请求范围延伸到"现在" → 覆盖到源数据最新就已经完整(其后本来没有内容)。
*
* freshness 与 coverage 共用这**一处**判据,避免两套口径漂移。
*/
function indexCovers(indexLatestAt: number | null, requestedEnd: number, sourceLatestAt: number | null): boolean {
if (indexLatestAt === null) return false
const requiredEnd = sourceLatestAt === null ? requestedEnd : Math.min(requestedEnd, sourceLatestAt)
return indexLatestAt + KNOWLEDGE_FRESHNESS_TOLERANCE_MS >= requiredEnd
}
/**
* `ResolvedTimeRange` 用的是 **epoch 秒**(Local Query API contract),
* 而 freshness 口径(indexLatestAt / sourceLatestAt)是 **epoch 毫秒**。
* 这里统一到毫秒,避免混单位把"落后"误判成"已覆盖"。
*/
function rangeEndMs(range: ResolvedTimeRange, fallbackNowMs: number): number {
return range.endTime === undefined ? fallbackNowMs : range.endTime * 1000
}
/** 本地时间(`MM-DD HH:mm`);只用于给模型一句可引用的人话,不参与任何判断。 */
function formatLocalMinute(ms: number): string {
const date = new Date(ms)
const pad = (value: number): string => String(value).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
* 索引覆盖结论:模型直接引用,不要自己换算时间、也不要输出 epoch 数字。
* 落后时必须把"这段时间暂时无法确认"写进结论句,避免被读成"整段时间都没有"。
*/
function buildIndexCoverage(
indexLatestAt: number | null,
sourceLatestAt: number | null,
covered: boolean
): QueryIndexCoverage | undefined {
if (indexLatestAt === null) return undefined
const indexLabel = formatLocalMinute(indexLatestAt)
return {
covered,
indexLatestAtLabel: indexLabel,
...(sourceLatestAt !== null ? { sourceLatestAtLabel: formatLocalMinute(sourceLatestAt) } : {}),
summary: covered
? `可搜索索引已覆盖所问的时间范围(索引最新到 ${indexLabel})。`
: `可搜索索引只更新到 ${indexLabel},这之后的聊天还没进索引,这段时间是否聊过暂时无法确认。`
}
}
const KIND_LABELS: Record<QueryMessageType, string> = {
text: '文本',
image: '图片',
voice: '语音',
video: '视频',
file: '文件',
link: '链接',
sticker: '表情',
system: '系统消息',
other: '消息'
}
function kindOf(message: FormattedMessage): QueryMessageType {
if (message.contentData?.type === 'system') return 'system'
if (message.exportMediaType) return message.exportMediaType
if (message.type === '语音' || message.voiceTranscript) return 'voice'
if (message.contentData?.type === 'image') return 'image'
if (message.contentData?.type === 'video') return 'video'
if (message.contentData?.type === 'sticker') return 'sticker'
if (message.contentData?.type === 'share') return message.contentData.typeVal === '6' ? 'file' : 'link'
if (message.contentData?.type === 'miniProgram') return 'link'
if (message.type === '文件') return 'file'
if (message.content?.trim()) return 'text'
return 'other'
}
function contactView(contact: FormattedContact) { return { displayName: contact.m_nsNickName || contact.m_nsUsrName, type: contact.type } as const }
function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTimeRange {
if (input.kind === 'absolute') {
if ((input.startTime !== undefined && !Number.isFinite(input.startTime)) || (input.endTime !== undefined && !Number.isFinite(input.endTime))) throw new Error('时间范围无效')
if (input.startTime !== undefined && input.endTime !== undefined && input.endTime < input.startTime) throw new Error('时间范围无效')
return { ...input, label: '指定时间范围' }
}
const map: Record<string, Parameters<typeof inferAiSearchTimeRange>[1]> = { all: 'all', today: 'today', yesterday: 'all', this_week: 'all', last_7_days: '7d', this_month: 'all', previous_month: 'all', this_year: 'all', previous_year: 'all' }
const phrase: Record<string, string> = { today: '今天', yesterday: '昨天', this_week: '本周', last_7_days: '近 7 天', this_month: '本月', previous_month: '上个月', this_year: '今年', previous_year: '去年', all: '' }
const range = inferAiSearchTimeRange(phrase[input.kind], map[input.kind], now)
return { kind: input.kind, startTime: range.startTime, endTime: range.endTime, label: range.label }
}
function toQueryMessage(conversationId: string, message: FormattedMessage, target: FormattedContact): QueryMessage {
const kind = kindOf(message)
const content = message.contentData
const attachment =
kind === 'image' && content?.type === 'image'
? { kind: 'image' as const }
: kind === 'video' && content?.type === 'video'
? { kind: 'video' as const, sizeBytes: content.byteLength }
: kind === 'sticker' && content?.type === 'sticker'
? { kind: 'sticker' as const, url: content.url || content.thumbUrl }
: kind === 'file'
? { kind: 'file' as const, name: message.exportMediaName || (content?.type === 'share' ? content.title : undefined), url: content?.type === 'share' ? content.url : undefined }
: undefined
const text = message.content?.trim() || message.voiceTranscript?.trim() || undefined
return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}) }
}
/**
* 单条证据的展示形态:群消息必须带**群名 + 发送者**,否则模型无法回答"谁聊过"。
* 不含 wxid / md5 / DB id;messageRef 保持 opaque。
*/
function toEvidenceItem(contact: FormattedContact, message: FormattedMessage): QueryEvidenceItem {
const view = toQueryMessage(contact.md5, message, contact)
const name = contactView(contact).displayName
return {
messageRef: view.messageRef,
timestamp: view.timestamp,
sender: view.sender,
sourceKind: view.sourceKind,
text: view.text || `[${KIND_LABELS[view.messageType]}]`,
conversationName: name,
conversationType: contact.type
}
}
/** 语料边界的解析结果。 */
interface ResolvedCorpus {
/** undefined = 不限会话(全部可读会话)。 */
conversationIds?: string[]
scope: ResolvedCorpusScope
/** contact / current 命中的会话。 */
contact?: FormattedContact
error?: { status: string; [key: string]: unknown }
}
function describeScope(scope: ResolvedCorpusScope): string {
if (scope.kind === 'all') return '所有聊天记录'
if (scope.kind === 'groups') return '群聊专属'
return `${scope.kind === 'contact' ? '单聊专属' : '当前会话'}:${scope.displayName || '未知会话'}`
}
/**
* 语料边界违规 → 返回**可修正**的 invalid_tool_arguments(Runtime 会重开该 Tool 让模型改)。
* 这是结构性约束:模型无法用 prompt 绕过。
*/
function outsideScopeError(scope: ResolvedCorpusScope, actual: string): { status: string; [key: string]: unknown } {
return {
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_outside_scope',
expected: describeScope(scope),
actual,
hint: 'target 必须落在应用当前的搜索范围内。需要查其他会话时,请让用户切换搜索范围;不要自行扩大范围。'
}
}
export class LocalQueryApiService {
constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {}
capabilities(): QueryCapabilitiesResponse {
return { version: 1, tools: { query_messages: { operation: '读取指定联系人的确定性消息', directions: ['any', 'from_target', 'to_target'], messageTypes: kinds, timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, search_messages: { operation: '受限 Knowledge 关键词检索', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, message_context: { operation: '读取消息前后文', timeRanges: ['all'], limitMax: CONTEXT_MAX }, conversation_overview: { operation: '按会话时间片提取概览证据', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX } } }
}
/**
* 解析语料边界。`scope` 由调用方(UI / Host)提供;省略 = 不限。
* 这里只做**确定性**展开(群列表 / 会话身份校验),不做任何语义推断。
*/
private async resolveCorpus(scope: QueryCorpusScope | undefined, contacts: FormattedContact[]): Promise<ResolvedCorpus> {
if (!scope || scope.kind === 'all') {
return { scope: { kind: 'all', conversationCount: contacts.length } }
}
if (scope.kind === 'groups') {
const ids = contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
return { conversationIds: ids, scope: { kind: 'groups', conversationCount: ids.length } }
}
const contact = contacts.find((item) => item.md5 === scope.conversationId)
if (!contact) {
return { scope: { kind: scope.kind, conversationCount: 0 }, error: { status: 'scope_conversation_not_found', kind: scope.kind } }
}
if (scope.kind === 'contact' && contact.type !== 'user') {
return { scope: { kind: scope.kind, conversationCount: 0 }, error: { status: 'scope_contact_requires_direct', actual: contact.type } }
}
return {
conversationIds: [contact.md5],
contact,
scope: { kind: scope.kind, conversationCount: 1, displayName: contactView(contact).displayName, conversationType: contact.type }
}
}
async messages(request: QueryMessagesRequest) {
if (!request?.timeRange || !['any', 'from_target', 'to_target'].includes(request.direction || 'any') || (request.messageTypes || []).some((type) => !kinds.includes(type))) return { status: 'invalid_request' as const }
if (!isReady()) return { status: 'knowledge_unavailable' as const }
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
const targetQuery = request.target?.query?.trim()
let contact: FormattedContact | undefined
if (targetQuery) {
const result = resolveContact(targetQuery, contacts)
if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
contact = contacts.find((c) => c.md5 === result.conversationId)!
if (corpus.conversationIds && !corpus.conversationIds.includes(contact.md5)) {
return outsideScopeError(corpus.scope, contactView(contact).displayName) as { status: string }
}
} else if (corpus.contact) {
// 省略 target:范围恰好只有一个会话(单聊专属 / 当前会话)时直接查它,避免让模型重新拼会话名。
contact = corpus.contact
} else if (corpus.conversationIds && corpus.conversationIds.length === 1) {
contact = contacts.find((c) => c.md5 === corpus.conversationIds![0])
}
if (!contact) {
return {
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_required_for_scope',
expected: describeScope(corpus.scope),
actual: '省略 target',
hint: '当前搜索范围包含多个会话。精确读取消息必须指定 target(必须在该范围内),或改用 search_messages 做跨会话检索。'
}
}
if (contact.type === 'group' && request.direction && request.direction !== 'any') return { status: 'unsupported_query' as const }; const range = resolvedTimeRange(request.timeRange, this.nowProvider())
const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds)
const filtered = raw.filter((message) => !(request.excludeSystem !== false && kindOf(message) === 'system')).filter((message) => allowed.has(kindOf(message))).filter((message) => direction === 'any' || (direction === 'to_target' ? message.isSender : !message.isSender)).sort((a, b) => ((a.createTime || 0) - (b.createTime || 0)) * ((request.order || 'asc') === 'asc' ? 1 : -1)).slice(0, Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)))
return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)), scope: corpus.scope }
}
async search(request: SearchMessagesRequest) {
const requestStartedAt = Date.now()
if (!request?.timeRange || typeof request.query !== 'string' || !request.query.trim() || (request.variants || []).some((value) => typeof value !== 'string')) return { status: 'invalid_request' as const }
// 真实耗时分解:每一段都用 Date.now() 实测,不做任何推断。
const scopeStartedAt = Date.now()
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
let conversationIds = corpus.conversationIds
let targetView: { displayName: string; type: 'user' | 'group' } | undefined
const targetQuery = request.target?.query?.trim()
if (targetQuery) {
const resolved = resolveContact(targetQuery, contacts)
if (!resolved.matched || !resolved.conversationId) {
return { status: resolved.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: resolved.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
}
const target = contacts.find((c) => c.md5 === resolved.conversationId)!
if (corpus.conversationIds && !corpus.conversationIds.includes(target.md5)) {
return outsideScopeError(corpus.scope, contactView(target).displayName) as { status: string }
}
conversationIds = [target.md5]
targetView = contactView(target)
} else if (corpus.conversationIds && corpus.conversationIds.length === 0) {
// 范围内没有任何会话(例如没有任何群聊):明确返回空,而不是悄悄退化成全局搜索。
return { status: 'completed' as const, coverage: { state: 'unknown' as const }, probeCount: 0, evidenceCount: 0, evidence: [], scope: corpus.scope }
}
const scopeMs = Date.now() - scopeStartedAt
if (!this.knowledge) return { status: 'knowledge_unavailable' as const }
const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const probes = [request.query, ...(request.variants || [])]
if (probes.length > 5) return { status: 'invalid_request' as const }
const contactByMd5 = new Map(contacts.map((contact) => [contact.md5, contact]))
const limit = Math.min(LIMIT_MAX, Math.max(1, request.limit || 20))
// 一次查询内多个 probe 共用同一个 retrieval session:Knowledge 会按它缓存
// "群会话 → 成员昵称" 的解析结果,否则每个 probe 都要重读一遍群成员快照,
// 跨会话检索会被放大成 N 倍。
const retrievalSessionId = `query-${this.nowProvider().getTime()}-${Math.random().toString(36).slice(2, 8)}`
// Freshness:请求的时间范围越过索引覆盖时,先请求既有增量通道去追一次;
// 这属于 Engine/Host 的确定性处理,**不增加 LLM 往返**。
//
// 整段交互检索期间必须让后台索引让路:追赶同步会遍历上千个会话,
// 否则本次查询会和它抢 Worker 与 WCDB,被拖成几十秒。
this.knowledge.beginInteractiveQuery()
const probeMs: number[] = []
let mergeMs = 0
let enrichmentMs = 0
let knowledgeTiming: NonNullable<QuerySearchTimings['knowledge']> | undefined
let found: Awaited<ReturnType<LocalQueryApiService['probe']>>
let freshness: { resynced: boolean; catchUp: 'none' | 'reused' | 'skipped' | 'completed' | 'pending' }
let freshnessMs = 0
try {
found = await this.probe(probes, conversationIds, range, limit, contactByMd5, retrievalSessionId)
probeMs.push(...found.probeMs)
mergeMs = found.mergeMs
enrichmentMs = found.enrichmentMs
knowledgeTiming = found.knowledge
const requestedEnd = rangeEndMs(range, this.nowProvider().getTime())
const freshnessStartedAt = Date.now()
freshness = await this.ensureFreshness(found, requestedEnd)
freshnessMs = Date.now() - freshnessStartedAt
// 触发过追赶就必须用(可能已更新的)索引重新检索,不能拿同步前的结果回答。
if (freshness.resynced) {
const reProbe = await this.probe(probes, conversationIds, range, limit, contactByMd5, retrievalSessionId)
probeMs.push(...reProbe.probeMs)
mergeMs += reProbe.mergeMs
enrichmentMs += reProbe.enrichmentMs
found = reProbe
}
} finally {
this.knowledge.endInteractiveQuery()
}
const requestedEnd = rangeEndMs(range, this.nowProvider().getTime())
const covered = indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt)
const indexCoverage = buildIndexCoverage(found.indexLatestAt, found.sourceLatestAt, covered)
const timings: QuerySearchTimings = {
totalMs: Date.now() - requestStartedAt,
scopeMs,
freshnessMs,
probeMs,
mergeMs,
enrichmentMs,
...(knowledgeTiming ? { knowledge: knowledgeTiming } : {})
}
return {
status: 'completed' as const,
...(targetView ? { target: targetView } : {}),
resolvedTimeRange: range,
coverage: { state: this.searchCoverage(found, requestedEnd) },
probeCount: probes.length,
evidenceCount: found.evidence.size,
evidence: Array.from(found.evidence.values()).slice(0, limit),
scope: corpus.scope,
indexLatestAt: found.indexLatestAt,
sourceLatestAt: found.sourceLatestAt,
freshness: { catchUp: freshness.catchUp },
...(indexCoverage ? { indexCoverage } : {}),
timings
}
}
/** 逐 probe 检索并合并去重;同时记录派生索引的覆盖口径与真实耗时分解。 */
private async probe(
probes: string[],
conversationIds: string[] | undefined,
range: ResolvedTimeRange,
limit: number,
contactByMd5: Map<string, FormattedContact>,
retrievalSessionId: string
): Promise<{
evidence: Map<string, QueryEvidenceItem>
indexLatestAt: number | null
sourceLatestAt: number | null
derivedReady: boolean
probeMs: number[]
mergeMs: number
enrichmentMs: number
knowledge?: NonNullable<QuerySearchTimings['knowledge']>
}> {
let indexLatestAt: number | null = null
let sourceLatestAt: number | null = null
let derivedReady = false
let enrichmentMs = 0
const probeMs: number[] = []
const knowledge = {
shortTermSearchMs: 0,
ftsMs: 0,
messageLoadMs: 0,
statusMs: 0,
voiceCoverageMs: 0,
workerExecutionMs: 0
}
// 先逐 probe 收证据,再统一合并:这样「probe 检索」与「合并去重」的耗时是分开测量的,
// 不会把合并成本摊到最后一个 probe 上(诊断时最容易被误读的地方)。
const perProbe: Array<Array<[string, QueryEvidenceItem]>> = []
for (const probe of probes) {
const probeStartedAt = Date.now()
const found = await this.knowledge!.search({
text: probe,
terms: [probe],
conversationIds,
startTime: range.startTime,
endTime: range.endTime,
limit,
retrievalSessionId
})
probeMs.push(Date.now() - probeStartedAt)
if (found.state === 'ready') derivedReady = true
if (typeof found.indexLatestAt === 'number' && (indexLatestAt === null || found.indexLatestAt > indexLatestAt)) indexLatestAt = found.indexLatestAt
if (typeof found.sourceLatestAt === 'number' && (sourceLatestAt === null || found.sourceLatestAt > sourceLatestAt)) sourceLatestAt = found.sourceLatestAt
const measured = found.timings
if (measured) {
knowledge.shortTermSearchMs += measured.shortTermSearchMs || 0
knowledge.ftsMs += measured.ftsMs || 0
knowledge.messageLoadMs += measured.messageLoadMs || 0
knowledge.statusMs += measured.statusMs || 0
knowledge.voiceCoverageMs += measured.voiceCoverageMs || 0
knowledge.workerExecutionMs += measured.workerExecutionMs || measured.totalMs || 0
enrichmentMs += measured.senderEnrichmentMs || 0
}
perProbe.push(
found.evidence.map((item) => {
const owner = contactByMd5.get(item.conversationId)
return [
`${item.conversationId}:${item.messageId}`,
{
messageRef: toRef(item.conversationId, item.messageId),
timestamp: item.timestamp,
sender: item.sender,
sourceKind: item.sourceKind,
text: item.text,
conversationName: owner ? contactView(owner).displayName : undefined,
conversationType: owner?.type
} satisfies QueryEvidenceItem
] as [string, QueryEvidenceItem]
})
)
}
const mergeStartedAt = Date.now()
const all = new Map<string, QueryEvidenceItem>()
for (const entries of perProbe) for (const [key, item] of entries) all.set(key, item)
const mergeMs = Date.now() - mergeStartedAt
const hasKnowledgeTiming =
knowledge.shortTermSearchMs > 0 ||
knowledge.ftsMs > 0 ||
knowledge.messageLoadMs > 0 ||
knowledge.workerExecutionMs > 0
return {
evidence: all,
indexLatestAt,
sourceLatestAt,
derivedReady,
probeMs,
mergeMs,
enrichmentMs,
...(hasKnowledgeTiming ? { knowledge } : {})
}
}
/**
* 索引新鲜度处理。
*
* 派生索引是异步的,可能停在几天前。请求范围越过索引覆盖时,**不能**直接把 0 条 Evidence
* 当成"没有",而是请求既有增量通道去追一次,并在有界预算内等它。
*/
private async ensureFreshness(
found: { indexLatestAt: number | null; sourceLatestAt: number | null; derivedReady: boolean },
requestedEnd: number
): Promise<{ resynced: boolean; catchUp: 'none' | 'reused' | 'skipped' | 'completed' | 'pending' }> {
if (!this.knowledge || !found.derivedReady) return { resynced: false, catchUp: 'none' }
if (indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt)) {
return { resynced: false, catchUp: 'none' }
}
// 落后量是否值得再跑一遍索引:门槛同时受"上一遍实际耗时"约束,
// 这样"源数据一直在长"时不会退化成永不停止的连续索引。
const lag = found.sourceLatestAt !== null && found.indexLatestAt !== null ? found.sourceLatestAt - found.indexLatestAt : Number.POSITIVE_INFINITY
const worthThreshold = Math.max(QUERY_CATCH_UP_MIN_LAG_MS, this.knowledge.lastPassDurationMs())
if (lag <= worthThreshold) return { resynced: false, catchUp: 'skipped' }
const request = this.knowledge.requestCatchUp(QUERY_CATCH_UP_MIN_INTERVAL_MS)
if (!request.triggered) {
// 已经在跑 → 复用;刚触发过 → 节流。两种都不阻塞本次查询,后台继续追。
return { resynced: false, catchUp: request.inProgress ? 'reused' : 'skipped' }
}
const completed = await this.knowledge.waitForIndexingComplete(QUERY_FRESHNESS_WAIT_BUDGET_MS)
return { resynced: true, catchUp: completed ? 'completed' : 'pending' }
}
/**
* 覆盖度。
*
* 只有「派生索引可用 + 请求范围被索引完整覆盖」才是 `complete`。
* `complete` 是 0 结果时允许说"没有找到"的唯一前提;索引落后时必须 `partial`。
*/
private searchCoverage(
found: { indexLatestAt: number | null; sourceLatestAt: number | null; derivedReady: boolean; evidence: Map<string, QueryEvidenceItem> },
requestedEnd: number
): 'complete' | 'partial' | 'unknown' {
if (!found.derivedReady) {
// 派生索引不可用(未建立 / 直读源数据的 fallback):有证据也只能算 partial。
return found.evidence.size ? 'partial' : 'unknown'
}
// 索引可用但覆盖口径缺失时不能宣称完整;此时按 partial 处理(宁可保守)。
if (found.indexLatestAt === null) return 'partial'
return indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt) ? 'complete' : 'partial'
}
async context(request: MessageContextRequest) {
const ref = fromRef(request.messageRef); if (!ref) return { status: 'invalid_request' as const }
if (request.scope && request.scope.kind !== 'all') {
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
if (corpus.conversationIds && !corpus.conversationIds.includes(ref.conversationId)) {
return outsideScopeError(corpus.scope, '其他会话的消息') as { status: string }
}
}
const before = Math.min(CONTEXT_MAX, Math.max(0, request.before ?? 10)); const after = Math.min(CONTEXT_MAX, Math.max(0, request.after ?? 10)); const messages = await listMessagesAsync(ref.conversationId); const index = messages.findIndex((message) => normalizeMessageIdentity(ref.conversationId, message.id)?.messageId === ref.messageId); if (index < 0) return { status: 'contact_not_found' as const }
const contact = (await listContactsAsync()).find((item) => item.md5 === ref.conversationId); if (!contact) return { status: 'contact_not_found' as const }; const map = (message: FormattedMessage) => toQueryMessage(ref.conversationId, message, contact)
return { status: 'completed' as const, anchor: map(messages[index]), before: messages.slice(Math.max(0, index - before), index).map(map), after: messages.slice(index + 1, index + 1 + after).map(map) }
}
/**
* 会话概览。
*
* **事实来源是 WCDB(源数据),不是派生 Knowledge 索引**:索引是异步派生的、可能滞后,
* 把"索引里 0 行"当成"完整范围内没有"会产生高置信度的错误否定。这里改为直读源数据
* 并显式区分 complete / partial。
*/
async overview(request: ConversationOverviewRequest) {
if (!request?.timeRange) return { status: 'invalid_request' as const }
if (!isReady()) return { status: 'knowledge_unavailable' as const }
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
const targetQuery = request.target?.query?.trim()
let contact: FormattedContact | undefined
if (targetQuery) {
const resolved = resolveContact(targetQuery, contacts)
if (!resolved.matched || !resolved.conversationId) {
return { status: resolved.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: resolved.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
}
contact = contacts.find((c) => c.md5 === resolved.conversationId)!
if (corpus.conversationIds && !corpus.conversationIds.includes(contact.md5)) {
return outsideScopeError(corpus.scope, contactView(contact).displayName) as { status: string }
}
} else if (corpus.contact) {
contact = corpus.contact
} else if (corpus.conversationIds && corpus.conversationIds.length === 1) {
contact = contacts.find((c) => c.md5 === corpus.conversationIds![0])
}
if (!contact) {
return {
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_required_for_scope',
expected: describeScope(corpus.scope),
actual: '省略 target',
hint: '当前搜索范围包含多个会话,会话概览只能针对单个会话。请显式指定 target(必须在该范围内),或改用 search_messages。'
}
}
const range = resolvedTimeRange(request.timeRange, this.nowProvider())
// 注意:这里**不能**给 listMessagesAsync 传 limit —— 实测在有界时间范围下
// `{ limit }` 会让 WCDB 读取返回 0 条(而同一范围不传 limit 能正常返回)。
// 与 query_messages 保持一致:读完整区间,再在 JS 侧截断/采样。
const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
const messages = raw.length > OVERVIEW_SOURCE_CAP ? raw.slice(-OVERVIEW_SOURCE_CAP) : raw
const truncated = raw.length > OVERVIEW_SOURCE_CAP
const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET)
const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete'
return {
status: 'completed' as const,
target: contactView(contact),
resolvedTimeRange: range,
coverage: { state },
sourceMessageCount: raw.length,
evidenceCount: evidence.length,
sourceCoverage: { state, sourceMessageCount: raw.length },
selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: truncated || evidence.length < messages.length },
evidence,
scope: corpus.scope,
origin: 'wcdb' as const
}
}
}
/**
* 时间片代表证据:按时间间隔切块,每块取"最长文本 / 首条 / 末条",再轮转挑选,
* 保证每个时间片都至少有一条代表,避免长会话里最近的时间片被整体丢弃。
*/
export function selectTemporalCoverageEvidence(
contact: FormattedContact,
messages: FormattedMessage[],
target: number
): QueryEvidenceItem[] {
if (!messages.length || target <= 0) return []
const chunks: FormattedMessage[][] = []
for (const message of messages) {
const current = chunks.at(-1)
const previous = current?.at(-1)
const gapMs = ((message.createTime || 0) - (previous?.createTime || 0)) * 1000
const isNewChunk = !current || gapMs > OVERVIEW_CHUNK_GAP_MS || current.length >= OVERVIEW_CHUNK_MAX_MESSAGES
if (isNewChunk) chunks.push([])
chunks.at(-1)!.push(message)
}
const representativesByChunk = chunks.map((chunk) => {
const preferred = chunk.filter((message) => kindOf(message) !== 'system')
const pool = preferred.length ? preferred : chunk
const ranked = [...pool].sort(
(left, right) =>
String(right.content || '').length - String(left.content || '').length ||
(right.createTime || 0) - (left.createTime || 0)
)
return [ranked[0], pool[0], pool.at(-1)].filter(
(value, index, items): value is FormattedMessage => Boolean(value) && items.indexOf(value) === index
)
})
const selected: FormattedMessage[] = []
for (let representativeIndex = 0; selected.length < target; representativeIndex += 1) {
let added = false
for (const representatives of representativesByChunk) {
const representative = representatives[representativeIndex]
if (representative && selected.length < target) {
selected.push(representative)
added = true
}
}
if (!added) break
}
return selected.map((message) => toEvidenceItem(contact, message))
}
@@ -0,0 +1,59 @@
import type {
ConversationOverviewRequest,
MessageContextRequest,
QueryMessagesRequest,
SearchMessagesRequest
} from '../../shared/local-query-api'
import type { LocalQueryApiService } from './local-query-api-service'
import type {
QueryAgentToolContext,
QueryAgentToolExecutor,
QueryAgentToolResult
} from './query-agent-service'
/**
* 进程内 Tool Executor:把 Query Agent 的 Tool 调用直接映射到 Local Query API Service。
*
* 桌面问问微信与 Agent Hub 共用这一份;不经过 HTTP、不需要 token、不依赖 6131 端口。
* Local Query API 的 public contract 未改变:时间仍是 epoch seconds(absolute 的 ISO-8601
* 已由 Runtime 在 Host 侧换算),temporalBasis 已在 Host 侧剥离。
*
* CLI 仍使用自己的 HTTP executor(它跑在独立进程里),属于 Adapter 层差异。
*/
export function createLocalQueryToolExecutor(
queryApi: LocalQueryApiService
): QueryAgentToolExecutor {
return async (
name: string,
input: Record<string, unknown>,
context?: QueryAgentToolContext
): Promise<QueryAgentToolResult> => {
// 语料边界由 Host 注入(LLM 无法提供,Tool schema 里也没有这个字段)。
// 越界 target 由 Engine 结构化拒绝,这里不做语义判断。
const request = context?.conversationScope
? { ...input, scope: context.conversationScope }
: input
switch (name) {
case 'query_messages':
// Local Query API 的 response 是显式 interface,没有 index signature,
// 这里显式窄化到 Runtime 的宽松 Tool Result 形状(Runtime 只读 status 与计数字段)。
return (await queryApi.messages(
request as unknown as QueryMessagesRequest
)) as unknown as QueryAgentToolResult
case 'search_messages':
return (await queryApi.search(
request as unknown as SearchMessagesRequest
)) as unknown as QueryAgentToolResult
case 'message_context':
return (await queryApi.context(
request as unknown as MessageContextRequest
)) as unknown as QueryAgentToolResult
case 'conversation_overview':
return (await queryApi.overview(
request as unknown as ConversationOverviewRequest
)) as unknown as QueryAgentToolResult
default:
throw new Error(`不允许的工具: ${name}`)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import { normalizeMessageIdentity } from '../../shared/local-query-api'
/**
* 「跳到原消息」的时间窗口规则。
*
* 抽成纯函数是因为它承载一条必须成立的性质:**锚点加载必须围绕 messageRef 的时间位置,
* 不能退化成加载整段会话历史** —— 最大会话可达数十万条消息,整段读既慢又会挤爆 IPC。
*/
export const MESSAGES_AROUND_MAX_WINDOW = 2000
/** 默认锚点半径 6 小时;真实数据上时间窗口是稀疏的,±6h 的量级很小。 */
export const MESSAGES_AROUND_DEFAULT_RADIUS_SECONDS = 6 * 3600
export const MESSAGES_AROUND_MIN_RADIUS_SECONDS = 60
export const MESSAGES_AROUND_MAX_RADIUS_SECONDS = 24 * 3600
export const MESSAGES_AROUND_MAX_WIDEN_FACTOR = 12
export const MESSAGES_AROUND_MAX_WIDEN_SECONDS = 3 * 24 * 3600
export const normalizeRadiusSeconds = (radiusSeconds?: number): number =>
Math.max(
MESSAGES_AROUND_MIN_RADIUS_SECONDS,
Math.min(radiusSeconds || MESSAGES_AROUND_DEFAULT_RADIUS_SECONDS, MESSAGES_AROUND_MAX_RADIUS_SECONDS)
)
export const widenRadiusSeconds = (baseRadius: number): number =>
Math.min(baseRadius * MESSAGES_AROUND_MAX_WIDEN_FACTOR, MESSAGES_AROUND_MAX_WIDEN_SECONDS)
/**
* 需要尝试的半径列表。
*
* **没有时间锚点时返回空数组** —— 这不是"退化成一个半径为 0 的查询"。
* 半径 0 会变成 `start=undefined / end=undefined`,也就是整段会话历史;
* 调用方必须据此直接给出「已打开对应会话,但暂时无法定位原消息」的降级文案。
*/
export const messagesAroundRadii = (
anchorSeconds: number | undefined,
baseRadius: number,
widenRadius: number
): number[] => (anchorSeconds && anchorSeconds > 0 ? [baseRadius, widenRadius] : [])
/** 有界窗口 + 精确身份定位。窗口按上限截断,绝不返回整段历史。 */
export const sliceMessagesAroundWindow = <T extends { id?: unknown }>(
messages: T[],
conversationId: string,
targetMessageId: string,
maxWindowMessages: number = MESSAGES_AROUND_MAX_WINDOW
): { window: T[]; index: number; truncated: boolean } => {
const truncated = messages.length > maxWindowMessages
const window = truncated ? messages.slice(0, maxWindowMessages) : messages
const index = window.findIndex(
(message) =>
normalizeMessageIdentity(conversationId, String(message.id ?? ''))?.messageId === targetMessageId
)
return { window, index, truncated }
}
@@ -0,0 +1,90 @@
import type {
PersonalWechatSendCapability,
PersonalWechatSendCapabilityState,
PersonalWechatSenderStatus
} from '../../shared/personal-wechat'
import {
personalWechatSendService,
type PersonalWechatSendService
} from './personal-wechat-send-service'
/**
* Converts the detailed sender diagnostics into a small contract that other
* features can consume without knowing about OneBot, Hook or platform details.
*/
export class PersonalWechatCapabilityService {
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
const senderStatus = await this.sender.getStatus()
return this.fromSenderStatus(senderStatus)
}
fromSenderStatus(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapability {
const capabilities = {
text: Boolean(senderStatus.canSendText),
image: Boolean(senderStatus.canSendImage),
voice: Boolean(senderStatus.canSendVoice)
}
const status = this.mapState(senderStatus)
const ready = status === 'ready'
return {
supported: status !== 'unsupported',
ready,
status,
capabilities,
senderStatus,
message:
status === 'unsupported'
? '微信消息发送目前仅支持 macOS 和 Windows'
: senderStatus.message ||
(status === 'needs_binding' || status === 'unconfigured'
? '请先绑定个人微信'
: status === 'initializing'
? '正在初始化微信发送能力'
: status === 'ready'
? '个人微信已准备好发送日报'
: senderStatus.error || '个人微信发送能力异常'),
...(senderStatus.error ? { error: senderStatus.error } : {})
}
}
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
if (senderStatus.platform === 'win32') {
if (senderStatus.canSend) return 'ready'
if (!senderStatus.endpoint) return 'unconfigured'
if (senderStatus.state === 'error' && senderStatus.endpointReady) return 'error'
return 'initializing'
}
if (senderStatus.platform !== 'darwin' || senderStatus.state === 'unsupported_platform') {
return 'unsupported'
}
if (senderStatus.state === 'error') return 'error'
const hasCurrentBinding = Boolean(
senderStatus.endpointReady &&
senderStatus.attachReady &&
senderStatus.wechatPid &&
senderStatus.boundWechatPid === senderStatus.wechatPid
)
if (!hasCurrentBinding) {
return senderStatus.runtimeReady ? 'needs_binding' : 'unconfigured'
}
if (senderStatus.canSend) return 'ready'
if (
senderStatus.state === 'hook_not_ready' ||
senderStatus.state === 'online' ||
senderStatus.state === 'starting' ||
senderStatus.state === 'stopped'
) {
return 'initializing'
}
return 'error'
}
}
export const personalWechatCapabilityService = new PersonalWechatCapabilityService(
personalWechatSendService
)
export const getPersonalWechatSendCapability = (): Promise<PersonalWechatSendCapability> =>
personalWechatCapabilityService.getPersonalWechatSendCapability()
@@ -21,38 +21,102 @@ const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e340914
function patchPerSendPayload(scriptPath: string): void { function patchPerSendPayload(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8') let source = readFileSync(scriptPath, 'utf8')
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
const declarations = 'var triggerX1Payload;\nvar triggerX0;' const activeSend = ` const payloadData = hexToByteArray(payloadHex);
const patchedDeclarations =
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length); activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData); activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr); activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0)); activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));` activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
if (!source.includes(declarations) || !source.includes(originalSend)) { 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('下载的发送组件与当前应用不兼容') throw new Error('下载的发送组件与当前应用不兼容')
} }
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend) 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')
writeFileSync(scriptPath, source)
}
function patchSendContextCapture(scriptPath: string, strict = true): void {
let source = 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('下载的微信版本与当前应用不兼容')
return
}
source = source.replace(original, patched)
writeFileSync(scriptPath, source)
}
function patchVoiceAudioBuffer(scriptPath: string, strict = true): void {
let source = 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)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace( source = source.replace(
' MMStartTask(triggerX0, triggerX1Payload);', staticAllocation,
' MMStartTask(triggerX0, activeTriggerX1Payload);' 'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
) )
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace( source = source.replace(
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', audioLengthMarker,
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);' `${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
source = source.replace(
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
) )
writeFileSync(scriptPath, source) writeFileSync(scriptPath, source)
} }
@@ -80,6 +144,181 @@ function patchImageHookReadiness(scriptPath: string): void {
writeFileSync(scriptPath, source) writeFileSync(scriptPath, source)
} }
function patchCdnColdStart(scriptPath: string, strict = true): void {
let source = 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)) {
if (strict) throw new Error('下载的微信版本配置与当前应用不兼容')
return
}
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)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
return
}
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)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体地址声明')
return
}
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)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传入口')
return
}
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)) {
if (strict) throw new Error('无法定位 wechat_chatter 语音上传入口')
return
}
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)) {
if (strict) throw new Error('无法定位 wechat_chatter 图片 Hook')
return
}
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)) {
if (strict) throw new Error('无法定位 wechat_chatter 下载 Hook')
return
}
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)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体下载入口')
return
}
source = source.replace(downloadGuard, patchedDownloadGuard)
writeFileSync(scriptPath, source)
}
function patchCdnColdStartConfig(configPath: string, strict = true): void {
let config: Record<string, unknown>
try {
config = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
} catch {
if (strict) throw new Error('4.1.11.53 版本配置不是有效 JSON')
return
}
config.cdnGetServiceAddr = '0x50a15d0'
config.cdnManagerGetterAddr = '0x5259290'
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
function patchWechatCoreModuleBase(scriptPath: string): void { function patchWechatCoreModuleBase(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8') let source = readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return if (source.includes('WeChat core module base:')) return
@@ -114,7 +353,8 @@ function addModifiedWorkNotice(scriptPath: string): void {
* Upstream: https://github.com/yincongcyincong/wechat_chatter * Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18 * Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0) * License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging. * 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. * These modifications are not provided by the upstream author.
*/ */
@@ -150,15 +390,30 @@ export class PersonalWechatRuntimeManager {
return this.buildStatus( return this.buildStatus(
'unsupported', 'unsupported',
0, 0,
process.platform === 'win32' process.platform === 'darwin'
? 'Windows 暂不支持个人微信发送组件' ? 'Intel Mac 不支持个人微信发送组件'
: `当前系统暂不支持个人微信发送组件:${process.platform} ${process.arch}` : process.platform === 'win32'
? 'Windows 暂不支持个人微信发送组件'
: '当前系统不支持个人微信发送组件'
) )
} }
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes) if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
const runtime = findPersonalWechatRuntime() const runtime = findPersonalWechatRuntime()
if (runtime) { if (runtime) {
try {
// Apply compatibility fixes to runtimes installed before this version.
patchWechatCoreModuleBase(join(runtime.workingDirectory, 'script.js'))
patchPerSendPayload(join(runtime.workingDirectory, 'script.js'))
patchSendContextCapture(join(runtime.workingDirectory, 'script.js'), false)
patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false)
patchImageHookReadiness(join(runtime.workingDirectory, 'script.js'))
patchCdnColdStart(join(runtime.workingDirectory, 'script.js'), false)
addModifiedWorkNotice(join(runtime.workingDirectory, 'script.js'))
patchCdnColdStartConfig(join(runtime.root, 'wechat_version', '4_1_11_53_mac.json'), false)
} catch {
// Status discovery should remain available even if an old runtime is read-only.
}
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root) return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
} }
@@ -259,9 +514,14 @@ export class PersonalWechatRuntimeManager {
const executable = join(stagedDirectory, 'onebot', 'onebot') const executable = join(stagedDirectory, 'onebot', 'onebot')
const script = join(stagedDirectory, 'onebot', 'script.js') const script = join(stagedDirectory, 'onebot', 'script.js')
const configPath = join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
patchCdnColdStartConfig(configPath)
patchWechatCoreModuleBase(script) patchWechatCoreModuleBase(script)
patchPerSendPayload(script) patchPerSendPayload(script)
patchSendContextCapture(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script) patchImageHookReadiness(script)
patchCdnColdStart(script)
addModifiedWorkNotice(script) addModifiedWorkNotice(script)
await chmod(executable, 0o755) await chmod(executable, 0o755)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { isAbsolute } from 'path'
import type {
PersonalWechatVoiceEncodingEnvironment,
PersonalWechatVoiceEncodingEnvironmentResult,
PersonalWechatVoiceRuntimeComponent
} from '../../shared/personal-wechat-voice-runtime'
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
import {
buildPersonalWechatRuntimeEnvironment,
findPersonalWechatRuntime
} from './personal-wechat-send-service'
import type { RuntimeLayout } from './personal-wechat-send-service'
import { appLogger } from '../app-logger'
const execFileAsync = promisify(execFile)
const COMMAND_TIMEOUT_MS = 10_000
const INSTALL_TIMEOUT_MS = 120_000
const PYTHON_PROBE =
'import json,sys; print(json.dumps({"executable":sys.executable,"version":".".join(map(str,sys.version_info[:3]))}))'
const PILK_PROBE =
'import json,pilk; print(json.dumps({"version":getattr(pilk,"__version__", ""),"path":getattr(pilk,"__file__", "")}))'
interface CommandOptions {
env: NodeJS.ProcessEnv
timeout?: number
}
type CommandRunner = (
executable: string,
args: string[],
options: CommandOptions
) => Promise<{ stdout: string; stderr: string }>
interface PersonalWechatVoiceEnvironmentServiceOptions {
platform?: NodeJS.Platform
architecture?: string
findRuntime?: () => RuntimeLayout | null
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
runCommand?: CommandRunner
now?: () => Date
}
function blankComponent(): PersonalWechatVoiceRuntimeComponent {
return { ready: false }
}
function commandError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.replace(/\s+/g, ' ').trim().slice(0, 500) || '命令执行失败'
}
function logEnvironmentLine(message: string, details?: Record<string, unknown>): void {
appLogger.write({ level: 'info', scope: 'VoiceRuntime', message, details })
}
function logEnvironmentWarning(message: string, details?: Record<string, unknown>): void {
appLogger.write({ level: 'warn', scope: 'VoiceRuntime', message, details })
}
async function runCommand(
executable: string,
args: string[],
options: CommandOptions
): Promise<{ stdout: string; stderr: string }> {
return execFileAsync(executable, args, {
env: options.env,
timeout: options.timeout || COMMAND_TIMEOUT_MS,
maxBuffer: 64 * 1024,
windowsHide: true
}) as Promise<{ stdout: string; stderr: string }>
}
async function findCommandPath(
command: string,
env: NodeJS.ProcessEnv,
runner: CommandRunner = runCommand
): Promise<string | undefined> {
try {
const { stdout } = await runner('/usr/bin/which', [command], { env })
const path = stdout.trim().split(/\r?\n/)[0]
return path && isAbsolute(path) ? path : undefined
} catch {
return undefined
}
}
function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment {
return {
state: 'unsupported',
ready: false,
runtimeReady: false,
python: blankComponent(),
pilk: blankComponent(),
ffmpeg: blankComponent(),
encoder: 'unavailable',
message: '语音编码环境检查当前仅支持 Apple Silicon Mac'
}
}
export class PersonalWechatVoiceEnvironmentService {
private readonly platform: NodeJS.Platform
private readonly architecture: string
private readonly findRuntime: () => RuntimeLayout | null
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv
private readonly runCommand: CommandRunner
private readonly now: () => Date
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
this.platform = options.platform || process.platform
this.architecture = options.architecture || process.arch
this.findRuntime = options.findRuntime || findPersonalWechatRuntime
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
this.runCommand = options.runCommand || runCommand
this.now = options.now || (() => new Date())
}
async check(): Promise<PersonalWechatVoiceEncodingEnvironment> {
if (this.platform !== 'darwin' || this.architecture !== 'arm64') {
return unsupportedEnvironment()
}
logEnvironmentLine('Checking voice encoding environment')
const runtime = this.findRuntime()
const environment = this.buildEnvironment(runtime?.root)
const python = blankComponent()
const pilk = blankComponent()
const ffmpeg = blankComponent()
let pythonExecutable = ''
try {
const result = await this.runCommand('python3', ['-c', PYTHON_PROBE], { env: environment })
const payload = JSON.parse(result.stdout.trim()) as {
executable?: string
version?: string
}
pythonExecutable = String(payload.executable || '').trim()
const version = String(payload.version || '').trim()
if (!pythonExecutable || !version) throw new Error('Python 未返回可执行路径或版本')
python.ready = true
python.executable = pythonExecutable
python.version = version
logEnvironmentLine(`Python: ${pythonExecutable}`)
logEnvironmentLine(`Python version: ${version}`)
} catch (error) {
python.error = commandError(error)
logEnvironmentWarning('Python: unavailable', { error: python.error })
}
if (pythonExecutable) {
try {
const result = await this.runCommand(pythonExecutable, ['-c', PILK_PROBE], {
env: environment
})
const payload = JSON.parse(result.stdout.trim()) as { version?: string; path?: string }
const version = String(payload.version || '').trim()
const path = String(payload.path || '').trim()
if (!version || !path) throw new Error('pilk 未返回版本或安装路径')
pilk.version = version
pilk.path = path
pilk.ready = version === PERSONAL_WECHAT_PILK_VERSION
if (!pilk.ready) {
pilk.error = `版本不匹配,需要 ${PERSONAL_WECHAT_PILK_VERSION}`
}
logEnvironmentLine(`pilk: ${pilk.ready ? 'available' : 'incompatible'}`, {
version,
path
})
logEnvironmentLine(`pilk version: ${version}`)
} catch (error) {
pilk.error = commandError(error)
logEnvironmentWarning('pilk: unavailable', { error: pilk.error })
}
} else {
pilk.error = 'Python 未找到,无法检查 pilk'
}
try {
const [result, executable] = await Promise.all([
this.runCommand('ffmpeg', ['-version'], { env: environment }),
findCommandPath('ffmpeg', environment, this.runCommand)
])
const firstLine = result.stdout.split(/\r?\n/).find((line) => line.trim()) || ''
const version = firstLine.match(/^ffmpeg version\s+([^\s]+)/i)?.[1] || firstLine.trim()
if (!version) throw new Error('ffmpeg 未返回版本')
ffmpeg.ready = true
ffmpeg.version = version
ffmpeg.executable = executable || 'ffmpeg'
logEnvironmentLine('ffmpeg: available', {
executable: ffmpeg.executable,
version
})
} catch (error) {
ffmpeg.error = commandError(error)
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
}
const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready)
const result: PersonalWechatVoiceEncodingEnvironment = {
state: ready ? 'ready' : 'incomplete',
ready,
checkedAt: this.now().toISOString(),
runtimeReady: Boolean(runtime),
...(runtime ? { runtimeRoot: runtime.root } : {}),
python,
pilk,
ffmpeg,
encoder: pilk.ready
? 'pilk'
: runtime && python.ready && ffmpeg.ready
? 'go-silk'
: 'unavailable',
message: ready
? '语音编码环境正常,可以使用 pilk 编码'
: runtime
? '语音编码环境不完整,OneBot 可能回退到 go-silk'
: '微信发送组件尚未安装,请先准备 OneBot 运行时'
}
logEnvironmentLine(
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
{ runtimeReady: result.runtimeReady, encoder: result.encoder }
)
logEnvironmentLine(`Encoder: ${result.encoder}`)
return result
}
async installPilk(): Promise<PersonalWechatVoiceEncodingEnvironmentResult> {
const before = await this.check()
const pythonExecutable = before.python.executable
if (!before.python.ready || !pythonExecutable) {
return {
success: false,
environment: before,
error: '未找到 TraceMemo 实际使用的 Python,无法安装 pilk'
}
}
logEnvironmentLine(`Installing pilk ${PERSONAL_WECHAT_PILK_VERSION}`, {
python: pythonExecutable
})
try {
await this.runCommand(
pythonExecutable,
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
{
env: this.buildEnvironment(before.runtimeRoot || undefined),
timeout: INSTALL_TIMEOUT_MS
}
)
} catch (error) {
const message = commandError(error)
logEnvironmentWarning('pilk installation failed', { error: message })
return {
success: false,
environment: await this.check(),
error: `pilk 安装失败:${message}`
}
}
const after = await this.check()
if (!after.pilk.ready) {
const message = after.pilk.error || '安装后仍无法 import pilk'
logEnvironmentWarning('pilk installation did not produce a usable module', {
error: message
})
return { success: false, environment: after, error: message }
}
return { success: true, environment: after }
}
}
export const personalWechatVoiceEnvironmentService = new PersonalWechatVoiceEnvironmentService()
@@ -0,0 +1,70 @@
import type { QueryAgentHistoryTurn } from './query-agent-service'
/**
* 最小多轮澄清上下文的边界(刻意写成显式常量,便于审计)。
*
* 只保留极少数轮次、极短文本、极短有效期 —— 目的是让"你说的是哪位联系人?"这类
* 澄清之后的下一句能被接上,而不是实现 Agent 长期记忆。
*/
export const CONVERSATION_MEMORY_MAX_TURNS = 2
export const CONVERSATION_MEMORY_QUESTION_MAX_CHARS = 300
export const CONVERSATION_MEMORY_ANSWER_MAX_CHARS = 800
export const CONVERSATION_MEMORY_TTL_MS = 10 * 60 * 1000
interface ConversationEntry {
turns: QueryAgentHistoryTurn[]
updatedAt: number
}
function truncate(value: string, maxChars: number): string {
const normalized = String(value || '')
.replace(/\s+/g, ' ')
.trim()
return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized
}
/**
* 有界的问答上下文。
*
* 隐私边界:
* - 只保存「用户问题 + Query Agent 最终回答」,**不保存** Tool 结果 / Evidence / 聊天原文;
* - 只存在内存,不落盘、不进日志、不跨进程;
* - 超过 TTL 或超过轮次上限即丢弃。
*/
export class QueryAgentConversationMemory {
private readonly entries = new Map<string, ConversationEntry>()
constructor(private readonly now: () => number = () => Date.now()) {}
history(key: string): QueryAgentHistoryTurn[] {
const entry = this.entries.get(key)
if (!entry) return []
if (this.now() - entry.updatedAt > CONVERSATION_MEMORY_TTL_MS) {
this.entries.delete(key)
return []
}
return entry.turns.map((turn) => ({ ...turn }))
}
record(key: string, question: string, answer: string): void {
const turn: QueryAgentHistoryTurn = {
question: truncate(question, CONVERSATION_MEMORY_QUESTION_MAX_CHARS),
answer: truncate(answer, CONVERSATION_MEMORY_ANSWER_MAX_CHARS)
}
if (!turn.question || !turn.answer) return
const previous = this.history(key)
this.entries.set(key, {
turns: [...previous, turn].slice(-CONVERSATION_MEMORY_MAX_TURNS),
updatedAt: this.now()
})
}
/** 只忘记某一路会话(例如用户点了「新问题」)。不传 key 时清空全部。 */
forget(key?: string): void {
if (key === undefined) {
this.entries.clear()
return
}
this.entries.delete(key)
}
}
+983
View File
@@ -0,0 +1,983 @@
/**
* Shared Query Agent runtime used by Ask WeChat, Agent Hub, and the CLI harness.
* Query semantics and tool orchestration are centralized here — prompt, tool mapping,
* validation, temporal policy, retry/stopping, and the bounded model loop — so every
* entry point behaves consistently; they differ only by injected tool executor and adapter.
*/
import type { AIChatToolCall, AIChatToolDefinition } from './ai-provider-service'
import {
LOCAL_QUERY_TOOL_DEFINITIONS,
type QueryCorpusScope,
type QuerySearchTimings,
type QueryTemporalBasisKind
} from '../../shared/local-query-api'
// 进度事件定义在 shared(renderer 也要用),这里只是把它带进本文件作用域。
import type {
QueryAgentProgressEvent,
QueryAgentProgressStage
} from '../../shared/query-agent'
const MAX_TOOL_CALLS = 5
const FORBIDDEN_INPUT_KEYS = new Set(['apiKey', 'authorization', 'token', 'databasePath', 'sql', 'wxid', 'md5'])
// 每个工具在“首次执行但结果为 0”之后允许的额外重试次数上限。
const ZERO_RESULT_RETRY_LIMIT = 1
// absolute 时间契约:LLM 只能给带时区的 ISO-8601 字符串,Host 负责换算成 Local Query API 的 epoch seconds。
const ISO_ABSOLUTE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/
// sanity 窗口:聊天记录不可能早于 2000 年,也不允许查询明显属于未来的区间。
const ABSOLUTE_MIN_MS = Date.UTC(2000, 0, 1)
const ABSOLUTE_MAX_FUTURE_MS = 366 * 24 * 60 * 60 * 1000
const ISO_ABSOLUTE_HINT = '带时区偏移的 ISO-8601,例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z'
interface ToolSchema {
type?: string
description?: string
required?: string[]
additionalProperties?: boolean
properties?: Record<string, ToolSchema>
items?: ToolSchema
enum?: unknown[]
minLength?: number
minimum?: number
maximum?: number
minItems?: number
maxItems?: number
}
export interface ToolArgumentValidationError {
status: 'invalid_tool_arguments'
field: string
constraint: string
expected?: unknown
actual?: unknown
[key: string]: unknown
}
export interface QueryAgentProvider {
getRuntimeConfig(): { configured: boolean; providerName: string; model: string; modelName: string }
chatWithTools(
messages: Array<Record<string, unknown>>,
tools: AIChatToolDefinition[]
): Promise<{
success: boolean
data?: string
toolCalls?: AIChatToolCall[]
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
error?: string
/** 以下为诊断字段(additive,不参与业务语义) */
elapsedMs?: number
errorStatus?: number
errorCode?: string
errorType?: string
errorContentType?: string
timedOut?: boolean
htmlInsteadOfJson?: boolean
}>
}
export interface QueryAgentToolResult {
status: string
[key: string]: unknown
}
export interface QueryAgentTraceItem {
toolName: string
input: Record<string, unknown>
durationMs: number
status: string
resultCount?: number
evidenceCount?: number
/** 会话概览覆盖的源消息条数(additive,用于 UI 顶部真实统计)。 */
sourceMessageCount?: number
/** LLM 声明的 temporalBasis。Host 消费它决定 policy,但不会传给 Local Query API。 */
temporalBasis?: { kind: QueryTemporalBasisKind; sourceText?: string }
/** Host 自动执行的扩大查询(当前仅 temporalBasis.kind=recall_hint + 有界范围 + 0 结果)。 */
autoFallback?: {
reason: 'soft_temporal_hint_zero_result'
timeRange: Record<string, unknown>
status: string
durationMs: number
resultCount?: number
evidenceCount?: number
}
/**
* Engine 侧的真实耗时分解(ADDITIVE 诊断)。
*
* 由 `search_messages` 的 Tool Result 携带,**不会进入模型上下文**(`toolResultForModel`
* 会剥离)。用途:把不透明的 Tool 总耗时拆成 scope / freshness / 每个 probe / 合并 / 证据补全。
*/
searchTimings?: QuerySearchTimings
}
export interface QueryAgentModelCallDiagnostic {
index: number
elapsedMs: number
status?: number
contentType?: string
timedOut?: boolean
htmlInsteadOfJson?: boolean
errorCode?: string
errorType?: string
error?: string
}
/**
* 失败分类(additive 诊断字段,供 Adapter 决定展示与是否允许 Legacy fallback)。
*
* 'provider_unavailable' = Provider 未配置;'provider_failure' = 模型请求本身失败
* (网络 / 上游 / 超时)。未分类的异常由 Adapter 归类为 runtime_error。
*/
export type QueryAgentErrorKind = 'invalid_question' | 'provider_unavailable' | 'provider_failure' | 'tool_limit'
/**
* 多轮澄清所需的最小历史。**由 Adapter 提供**,Runtime 只负责按顺序放进 messages。
* Runtime 自身仍然是无状态单轮执行器;历史长度 / 保留时间的边界由调用方负责。
*/
export interface QueryAgentHistoryTurn {
question: string
answer: string
}
export interface QueryAgentResult {
question: string
provider: string
model: string
modelCallCount: number
toolCallCount: number
firstModelMs?: number
toolTotalMs: number
finalModelMs?: number
totalMs: number
/** 每次模型调用的耗时(含失败的那次),按调用顺序;additive 诊断字段 */
modelDurationsMs: number[]
/** 每次模型调用的请求级诊断;additive 诊断字段 */
modelDiagnostics: QueryAgentModelCallDiagnostic[]
traces: QueryAgentTraceItem[]
answer?: string
error?: string
/** 失败分类;additive 诊断字段,成功时为 undefined */
errorKind?: QueryAgentErrorKind
/** 本次回答实际依据的证据(additive);按首次命中顺序去重。 */
evidence?: QueryAgentEvidenceItem[]
}
/**
* 语料边界(conversation scope):由 UI / Adapter 传入,**不是** LLM 的输入。
* `label` 只用于给模型描述"当前范围是什么",强制逻辑完全在 Engine(target 越界会被拒绝)。
*/
export interface QueryAgentConversationScope {
scope: QueryCorpusScope
label?: string
}
/** 单次 Tool 执行的上下文(由 Runtime 注入,LLM 无法提供)。 */
export interface QueryAgentToolContext {
conversationScope?: QueryCorpusScope
}
export type QueryAgentToolExecutor = (
name: string,
input: Record<string, unknown>,
context?: QueryAgentToolContext
) => Promise<QueryAgentToolResult>
/**
* 本次回答实际依据的证据(ADDITIVE,供 Adapter 展示)。
* 只保留可展示字段;messageRef 仍是 opaque 引用。
*/
export interface QueryAgentEvidenceItem {
messageRef: string
conversationName?: string
conversationType?: 'user' | 'group'
sender?: string
/** epoch ms */
timestamp?: number
messageType?: string
text?: string
attachment?: { kind?: string; name?: string; url?: string; sizeBytes?: number }
/** 产生这条证据的 Tool 名(诊断 / UI 分组用)。 */
source: string
}
/** 一次回答最多带出多少条证据(IPC 体积与 UI 噪声控制)。 */
const MAX_EVIDENCE_ITEMS = 40
const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使用提供的四个 Query Tool 获取事实,最终回答只基于 Tool Result。
规划原则:
- 先判断问题需要哪种证据,再调用最少的 Tool。每次收到 Tool Result 后都判断“当前 Evidence 是否已经足以给出有边界的回答”;足够就立即回答,不为追求绝对完整继续调查。
- query_messages 是精确事实查询,适用于能用联系人、时间、方向、消息类型、顺序等结构条件表达的问题。earliest/latest 等时间边界也是结构条件,必须使用 order 与 limit 精确查询,不能使用抽样 overview。每次调用都必须如实声明 temporalBasis。结果已经回答问题时,不要追加 conversation_overview。
- 需要绝对时间范围时,startTime/endTime 必须使用带时区偏移的 ISO-8601 字符串(例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z)。不要传 epoch 数字,也不要传没有时区的裸本地时间。
- search_messages 是关键词检索,适用于结构条件无法确定答案的问题。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话塞进同一项。首次最多 4 项。检索到 Evidence 后直接判断;只有本次完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。
- conversation_overview 只用于真正需要理解一个时间范围内整体聊了什么、主要话题或整体互动的 broad summary。它返回 temporal coverage sample,不代表完整聊天,也不是检索不足时的默认 fallback。
- message_context 只用于已经找到一条有价值 Evidence、但单条内容缺少前后语境而无法判断真实含义的情况。不要把它当作默认确认步骤;上下文足够后立即回答。
- 普通聊天查询不是 exhaustive investigation。经过合理的检索或可选 context 仍不足以形成强结论时,直接说明证据范围和不确定性,不要循环调用 search、overview、context。
事实边界:不得编造未返回的消息、猜测联系人、修改 resolvedTimeRange,或把 partial/unknown 当作 complete。coverage complete 且结果为 0 时,可以说明当前可读取的完整范围没有找到;coverage partial/unknown 且结果为 0 时,必须说明无法确认绝对不存在。不要把 sampled Evidence 当作完整聊天,也不要把 source message count 和 selected evidence count 混为一谈。
索引新鲜度:search_messages 的 indexLatestAt / sourceLatestAt 是**结构化事实**,indexCoverage 是 Engine 给出的结论句。规则:
- 覆盖边界只能引用 indexCoverage(含本地时间与结论),**不要自己换算时间,也不要把 epoch 数字写进回答**。
- indexCoverage.covered 为 false 时,说明这段时间还没进索引:此时即使结果为 0 也只能说"索引尚未覆盖这段时间,暂时无法确认",**绝不能**说成"没有"。必须如实引用结论里的索引更新时间。
- 已经检索到 Evidence 时,只有当这个覆盖边界真的会影响结论时才补一句说明,不要机械附加警告。
- 只有 coverage.state 为 complete(indexCoverage.covered 为 true)且结果为 0,才可以下"没有找到"的结论。不要自己把 partial 说成 complete。
缺少必要信息时用自然语言澄清;超出工具能力时说明不能可靠完成,并给出当前工具可以执行的替代方向。`
function toolDefinitions(): AIChatToolDefinition[] {
return LOCAL_QUERY_TOOL_DEFINITIONS.map((tool) => ({
type: 'function' as const,
function: { name: tool.name, description: tool.description, parameters: tool.parameters }
}))
}
function toolDefinition(name: string): AIChatToolDefinition[] {
return toolDefinitions().filter((tool) => tool.function.name === name)
}
function sanitizeInput(input: Record<string, unknown>): Record<string, unknown> {
const sanitizeValue = (value: unknown): unknown => {
if (typeof value === 'string') return value.length > 240 ? `${value.slice(0, 240)}...` : value
if (Array.isArray(value)) return value.slice(0, 8).map(sanitizeValue)
if (value && typeof value === 'object') return sanitizeInput(value as Record<string, unknown>)
return value
}
const output: Record<string, unknown> = {}
for (const [key, value] of Object.entries(input)) {
if (key === 'messageRef') output[key] = '[opaque-message-ref]'
else if (!FORBIDDEN_INPUT_KEYS.has(key)) output[key] = sanitizeValue(value)
}
return output
}
function containsForbiddenKey(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsForbiddenKey)
if (!value || typeof value !== 'object') return false
return Object.entries(value as Record<string, unknown>).some(([key, child]) => FORBIDDEN_INPUT_KEYS.has(key) || containsForbiddenKey(child))
}
function actualType(value: unknown): string {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value
}
function schemaTypeMatches(value: unknown, type: string): boolean {
if (type === 'object') return Boolean(value && typeof value === 'object' && !Array.isArray(value))
if (type === 'integer') return typeof value === 'number' && Number.isInteger(value)
if (type === 'number') return typeof value === 'number' && Number.isFinite(value)
return actualType(value) === type
}
function validateSchema(value: unknown, schema: ToolSchema, field = '$'): ToolArgumentValidationError | undefined {
if (schema.type && !schemaTypeMatches(value, schema.type)) {
return { status: 'invalid_tool_arguments', field, constraint: 'type', expected: schema.type, actual: actualType(value) }
}
if (schema.enum && !schema.enum.some((allowed) => Object.is(allowed, value))) {
return { status: 'invalid_tool_arguments', field, constraint: 'enum', expected: schema.enum, actual: value }
}
if (typeof value === 'string') {
if (schema.minLength !== undefined && value.length < schema.minLength) {
return { status: 'invalid_tool_arguments', field, constraint: 'minLength', expected: schema.minLength, actual: value.length }
}
}
if (typeof value === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) {
return { status: 'invalid_tool_arguments', field, constraint: 'minimum', expected: schema.minimum, actual: value }
}
if (schema.maximum !== undefined && value > schema.maximum) {
return { status: 'invalid_tool_arguments', field, constraint: 'maximum', expected: schema.maximum, actual: value }
}
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) {
return { status: 'invalid_tool_arguments', field, constraint: 'minItems', expected: schema.minItems, actual: value.length }
}
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
return { status: 'invalid_tool_arguments', field, constraint: 'maxItems', expected: schema.maxItems, actual: value.length }
}
if (schema.items) {
for (let index = 0; index < value.length; index += 1) {
const error = validateSchema(value[index], schema.items, `${field}[${index}]`)
if (error) return error
}
}
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const objectValue = value as Record<string, unknown>
for (const required of schema.required || []) {
if (!(required in objectValue)) {
return { status: 'invalid_tool_arguments', field: field === '$' ? required : `${field}.${required}`, constraint: 'required', expected: true, actual: false }
}
}
const properties = schema.properties || {}
if (schema.additionalProperties === false) {
for (const key of Object.keys(objectValue)) {
if (!(key in properties)) {
return { status: 'invalid_tool_arguments', field: field === '$' ? key : `${field}.${key}`, constraint: 'additionalProperties', expected: false, actual: true }
}
}
}
for (const [key, childSchema] of Object.entries(properties)) {
if (key in objectValue) {
const error = validateSchema(objectValue[key], childSchema, field === '$' ? key : `${field}.${key}`)
if (error) return error
}
}
}
return undefined
}
function argError(field: string, constraint: string, expected?: unknown, actual?: unknown, hint?: string): ToolArgumentValidationError {
return { status: 'invalid_tool_arguments', field, constraint, ...(expected === undefined ? {} : { expected }), ...(actual === undefined ? {} : { actual }), ...(hint ? { hint } : {}) }
}
/**
* 解析 LLM 提供的绝对时间。只接受带显式时区偏移(或 Z)的 ISO-8601;
* 无时区、epoch 数字、非法日历日一律返回 undefined,由调用方转成可修正的 invalid_tool_arguments。
*/
function parseIsoInstant(value: string): number | undefined {
const match = ISO_ABSOLUTE_PATTERN.exec(value.trim())
if (!match) return undefined
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const hour = Number(match[4])
const minute = Number(match[5])
const second = Number(match[6])
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return undefined
// 先在 UTC 语义下校验字面日期真实存在,再套用时区偏移,避免 2 月 31 日被静默进位。
const naiveMs = Date.UTC(year, month - 1, day, hour, minute, second)
const naive = new Date(naiveMs)
if (naive.getUTCFullYear() !== year || naive.getUTCMonth() !== month - 1 || naive.getUTCDate() !== day) return undefined
const offset = match[7]
if (offset === 'Z') return naiveMs
const sign = offset.startsWith('-') ? -1 : 1
const offsetHour = Number(offset.slice(1, 3))
const offsetMinute = Number(offset.slice(4, 6))
if (offsetHour > 23 || offsetMinute > 59) return undefined
return naiveMs - sign * (offsetHour * 60 + offsetMinute) * 60000
}
function canonicalizeTimeRange(timeRange: Record<string, unknown>, now: Date): { value?: Record<string, unknown>; error?: ToolArgumentValidationError } {
const startTime = timeRange.startTime
const endTime = timeRange.endTime
if (timeRange.kind !== 'absolute') {
// 非 absolute 语义下 startTime/endTime 无意义,直接丢弃,避免残留数值进入 Query API。
const { startTime: _startTime, endTime: _endTime, ...rest } = timeRange
return { value: rest }
}
if (typeof startTime !== 'string' || typeof endTime !== 'string') {
return { error: argError('timeRange.startTime', 'required', ISO_ABSOLUTE_HINT, actualType(startTime ?? endTime), ISO_ABSOLUTE_HINT) }
}
const startMs = parseIsoInstant(startTime)
if (startMs === undefined) return { error: argError('timeRange.startTime', 'format', ISO_ABSOLUTE_HINT, startTime, ISO_ABSOLUTE_HINT) }
const endMs = parseIsoInstant(endTime)
if (endMs === undefined) return { error: argError('timeRange.endTime', 'format', ISO_ABSOLUTE_HINT, endTime, ISO_ABSOLUTE_HINT) }
if (endMs < startMs) return { error: argError('timeRange.endTime', 'range_order', 'endTime 不得早于 startTime', endTime) }
const latestMs = now.getTime() + ABSOLUTE_MAX_FUTURE_MS
const sanityWindow = `2000-01-01 至 ${new Date(latestMs).toISOString()}`
if (startMs < ABSOLUTE_MIN_MS || startMs > latestMs) return { error: argError('timeRange.startTime', 'range_sanity', sanityWindow, startTime) }
if (endMs < ABSOLUTE_MIN_MS || endMs > latestMs) return { error: argError('timeRange.endTime', 'range_sanity', sanityWindow, endTime) }
// 通过全部校验后才换算成 Local Query API 使用的 epoch seconds。
return { value: { kind: 'absolute', startTime: Math.floor(startMs / 1000), endTime: Math.floor(endMs / 1000) } }
}
/** canonical(已剥离、已校验)的 temporalBasis。 */
interface CanonicalTemporalBasis {
kind: QueryTemporalBasisKind
sourceText?: string
}
/**
* LLM Tool Adapter 的 canonicalization:剥离 LLM-facing 元数据(temporalBasis)、把 absolute 的
* ISO-8601 换成 Local Query API 的 epoch seconds、把 `queries[]` 摊平成 query + variants。
*
* 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串、kind 与 timeRange 是否自洽),
* 不解释时间短语的意思。
*/
function canonicalizeToolInput(
name: string,
input: Record<string, unknown>,
now: Date,
question: string
): { input?: Record<string, unknown>; temporalBasis?: CanonicalTemporalBasis; error?: ToolArgumentValidationError } {
const output: Record<string, unknown> = { ...input }
const rawBasis = output.temporalBasis
delete output.temporalBasis
let temporalBasis: CanonicalTemporalBasis | undefined
if (rawBasis && typeof rawBasis === 'object' && !Array.isArray(rawBasis)) {
const record = rawBasis as Record<string, unknown>
const kind = record.kind
if (kind === 'constraint' || kind === 'recall_hint' || kind === 'none') {
const sourceText = typeof record.sourceText === 'string' ? record.sourceText.trim() : undefined
if (kind === 'none') {
if (sourceText) {
return { error: argError('temporalBasis.sourceText', 'forbidden_for_none', null, sourceText, 'kind=none 表示问题里没有任何时间表达,此时不要提供 sourceText。') }
}
} else if (!sourceText) {
return { error: argError('temporalBasis.sourceText', 'required', '用户原问题中的时间原文片段', undefined, `kind=${kind} 时必须给出用户原问题中实际出现的时间片段。`) }
} else if (!question.includes(sourceText)) {
return { error: argError('temporalBasis.sourceText', 'source_not_in_question', question, sourceText, 'sourceText 必须是用户原问题中逐字出现的片段,不要改写、翻译或补全。') }
}
temporalBasis = { kind, ...(sourceText ? { sourceText } : {}) }
}
}
if (output.timeRange && typeof output.timeRange === 'object' && !Array.isArray(output.timeRange)) {
const canonical = canonicalizeTimeRange(output.timeRange as Record<string, unknown>, now)
if (canonical.error) return { error: canonical.error }
output.timeRange = canonical.value
}
// 用户没给任何时间表达时,不得凭空造一个有界范围(earliest/latest 用 order/limit 表达)。
if (temporalBasis?.kind === 'none') {
const kind = rangeKind(output)
if (kind && kind !== 'all') {
return { error: argError('timeRange', 'temporal_basis_mismatch', 'timeRange.kind=all', output.timeRange, '用户没有给出任何时间表达(temporalBasis.kind=none)。请改用 timeRange.kind=all;如果需要 earliest/latest 这类边界,用 order 与 limit 表达。') }
}
}
if (name === 'search_messages') {
const raw = Array.isArray(output.queries) ? (output.queries as unknown[]) : []
const probes = raw.filter((value): value is string => typeof value === 'string').map((value) => value.trim()).filter(Boolean)
if (!probes.length) return { error: argError('queries', 'required', '至少一个非空检索项') }
const [first, ...rest] = probes
delete output.queries
output.query = first
if (rest.length) output.variants = rest
}
return { input: output, temporalBasis }
}
interface ZeroResultRetryState {
searchAttempts: number
searchSignatures: string[]
queryAttempts: number
querySignatures: string[]
/**
* temporalBasis.kind=constraint 的 query_messages 一旦执行,就锁定其 canonical timeRange。
* 后续 retry 若替换时间范围会被拒绝 —— 用户明确给出的时间边界不得扩大。
*/
lockedConstraintRange?: { signature: string; timeRange: Record<string, unknown> }
}
function newRetryState(): ZeroResultRetryState {
return { searchAttempts: 0, searchSignatures: [], queryAttempts: 0, querySignatures: [] }
}
/** Host 自动执行的扩大查询原因(当前只有一种)。 */
const AUTO_FALLBACK_REASON = 'soft_temporal_hint_zero_result' as const
function rangeKind(input: Record<string, unknown>): string | undefined {
const range = input.timeRange
if (!range || typeof range !== 'object' || Array.isArray(range)) return undefined
const kind = (range as Record<string, unknown>).kind
return typeof kind === 'string' ? kind : undefined
}
/**
* constraint 时间边界锁定:时间来源由 LLM 判断(temporalBasis),
* 但"不得扩大"由 Host 结构性保证,不依赖 prompt。
*/
function lockConstraintTimeRange(
name: string,
temporalBasis: CanonicalTemporalBasis | undefined,
input: Record<string, unknown>,
state: ZeroResultRetryState
): ToolArgumentValidationError | undefined {
if (name !== 'query_messages' || temporalBasis?.kind !== 'constraint') return undefined
const signature = JSON.stringify(input.timeRange ?? null)
if (!state.lockedConstraintRange) {
state.lockedConstraintRange = { signature, timeRange: (input.timeRange as Record<string, unknown>) ?? {} }
return undefined
}
if (state.lockedConstraintRange.signature === signature) return undefined
return argError('timeRange', 'constraint_time_range_immutable', state.lockedConstraintRange.timeRange, input.timeRange, '用户明确给出的时间范围不得改变;只能放宽 direction / messageTypes 等非时间条件。')
}
/**
* 只有"LLM 自己推断的近似时间范围(recall_hint)+ 有界范围 + 首次 0 结果"才自动扩大。
* 已经是 all 时无需扩大;constraint 一律不扩大(由 lockConstraintTimeRange 保证)。
*/
function shouldAutoBroaden(
name: string,
temporalBasis: CanonicalTemporalBasis | undefined,
input: Record<string, unknown>,
result: QueryAgentToolResult
): boolean {
if (name !== 'query_messages') return false
if (temporalBasis?.kind !== 'recall_hint') return false
if (resultCount(result).resultCount !== 0) return false
return rangeKind(input) !== 'all'
}
function normalizedTarget(input: Record<string, unknown>): string {
const target = input.target
const query = target && typeof target === 'object' ? (target as Record<string, unknown>).query : undefined
return typeof query === 'string' ? query.trim().toLowerCase() : ''
}
/** 只覆盖“实质条件”:忽略 limit/order/excludeSystem 这类不改变检索语义的字段。 */
function retrySignature(name: string, input: Record<string, unknown>): string {
if (name === 'search_messages') {
const probes = [input.query, ...(Array.isArray(input.variants) ? input.variants : [])]
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim().toLowerCase())
return JSON.stringify({ target: normalizedTarget(input), timeRange: input.timeRange ?? null, probes: Array.from(new Set(probes)).sort() })
}
const messageTypes = Array.isArray(input.messageTypes) ? [...(input.messageTypes as string[])].sort() : []
return JSON.stringify({ target: normalizedTarget(input), timeRange: input.timeRange ?? null, direction: input.direction ?? null, messageTypes })
}
function duplicateRetry(name: string, input: Record<string, unknown>, state: ZeroResultRetryState): boolean {
const signature = retrySignature(name, input)
if (name === 'search_messages') return state.searchSignatures.includes(signature)
if (name === 'query_messages') return state.querySignatures.includes(signature)
return false
}
function recordAttempt(name: string, input: Record<string, unknown>, state: ZeroResultRetryState): void {
if (name === 'search_messages') { state.searchAttempts += 1; state.searchSignatures.push(retrySignature(name, input)) }
else if (name === 'query_messages') { state.queryAttempts += 1; state.querySignatures.push(retrySignature(name, input)) }
}
function retryNote(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState): string | undefined {
if (result.constraint === 'duplicate_retry') return '本次重试的条件与上一次完全相同,已被拒绝;请改用实质不同的条件,或直接基于现有结果作答。'
if (result.status !== 'completed') return undefined
const counts = resultCount(result)
if (name === 'search_messages' && !counts.evidenceCount && state.searchAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次检索没有任何 Evidence。允许再执行一次 search_messages,但每一项都必须与上一次实质不同;完全相同的检索会被拒绝。'
if (name === 'query_messages' && counts.resultCount === 0 && !result.fallbackLookup && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次精确查询返回 0 条。允许再执行一次 query_messages,用于放宽 direction 或 messageTypes 等非时间条件;改变时间范围会被拒绝。'
return undefined
}
export function validateToolArguments(
name: string,
value: unknown,
now: Date = new Date(),
question = ''
): { input?: Record<string, unknown>; temporalBasis?: CanonicalTemporalBasis; error?: ToolArgumentValidationError } {
const definition = LOCAL_QUERY_TOOL_DEFINITIONS.find((tool) => tool.name === name)
if (!definition) return { error: argError('$', 'tool', 'supported tool', name) }
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { error: argError('$', 'type', 'object', actualType(value)) }
}
if (containsForbiddenKey(value)) {
return { error: argError('$', 'forbidden_field') }
}
const error = validateSchema(value, definition.parameters as ToolSchema)
if (error) return { error }
return canonicalizeToolInput(name, value as Record<string, unknown>, now, question)
}
function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number; sourceMessageCount?: number } {
return {
resultCount: typeof result.returnedCount === 'number' ? result.returnedCount : undefined,
evidenceCount: typeof result.evidenceCount === 'number' ? result.evidenceCount : Array.isArray(result.evidence) ? result.evidence.length : undefined,
// 会话概览用它说明"覆盖了多少条源消息",UI 顶部统计需要真实数字。
sourceMessageCount: typeof result.sourceMessageCount === 'number' ? result.sourceMessageCount : undefined
}
}
function messageRecordForModel(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
const record = value as Record<string, unknown>
return record.messageType || !record.sourceKind ? record : { ...record, messageType: record.sourceKind }
}
function toolResultForModel(name: string, result: QueryAgentToolResult, callsUsed: number, nextTools: AIChatToolDefinition[], note?: string): QueryAgentToolResult {
const visible: QueryAgentToolResult = { ...result }
if (Array.isArray(result.messages)) visible.messages = result.messages.map(messageRecordForModel)
if (Array.isArray(result.evidence)) visible.evidence = result.evidence.map(messageRecordForModel)
if (result.anchor) visible.anchor = messageRecordForModel(result.anchor)
if (Array.isArray(result.before)) visible.before = result.before.map(messageRecordForModel)
if (Array.isArray(result.after)) visible.after = result.after.map(messageRecordForModel)
if (result.fallbackLookup && typeof result.fallbackLookup === 'object') {
const fallback = result.fallbackLookup as Record<string, unknown>
visible.fallbackLookup = {
...fallback,
...(Array.isArray(fallback.messages) ? { messages: fallback.messages.map(messageRecordForModel) } : {})
}
}
visible._agent = {
toolName: name,
toolCallsUsed: callsUsed,
toolCallsRemaining: Math.max(0, MAX_TOOL_CALLS - callsUsed),
availableNextTools: nextTools.map((tool) => tool.function.name),
...(note ? { note } : {}),
instruction: [
nextTools.length
? '先判断当前 Evidence 是否足以回答;足够就立即回答,只在含义仍有明确歧义时使用当前可用 Tool。'
: '工具阶段已经结束。必须直接给出有边界的最终回答,不得再调用 Tool。',
result.fallbackLookup
? '本次结果有两个 scope:顶层是原查询,fallbackLookup 是系统自动扩大到全部历史后的结果。回答时必须分别说明这两个范围,不要让用户以为原问题就是按“全部历史”提出的。'
: undefined,
note
].filter(Boolean).join(' ')
}
return visible
}
function nextToolDefinitions(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState, rangeWasAll = false): AIChatToolDefinition[] {
// 重复重试已被拒绝,不再开放工具,避免用有限的 tool budget 反复试同一条件。
if (result.constraint === 'duplicate_retry') return []
if (result.status === 'invalid_tool_arguments') return toolDefinition(name)
if (result.status !== 'completed') return []
const counts = resultCount(result)
if (name === 'search_messages') {
// 有 Evidence 时保持原有高效路径:只允许补一次上下文。
if (counts.evidenceCount) return toolDefinition('message_context')
// 首次检索完全没有 Evidence:允许一次实质不同的重试,之后关闭。
return state.searchAttempts <= ZERO_RESULT_RETRY_LIMIT ? toolDefinition('search_messages') : []
}
if (name === 'query_messages') {
// Host 已经自动执行过一次扩大查询:不再开放 retry,避免出现第三次查询。
if (result.fallbackLookup) return []
// 已经查了全部历史且 0 结果:再换时间范围毫无意义(更窄只会更少)。
if (rangeWasAll && counts.resultCount === 0) return []
// 只有 0 结果才开放一次重试;有结果时保持原有 stopping。
return counts.resultCount === 0 && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT ? toolDefinition('query_messages') : []
}
return []
}
/** 进度阶段的类型定义在 `src/shared/query-agent.ts`(renderer 也要消费它,放这里会让 renderer 反向依赖 main)。 */
export type { QueryAgentProgressEvent, QueryAgentProgressStage } from '../../shared/query-agent'
export interface QueryAgentRunOptions {
/**
* 最小多轮澄清支持:前几轮(问 + 答)的问答对,由 Adapter 负责长度 / 时间 / 隐私边界。
* 不传时 messages = [system, user]。
*/
history?: QueryAgentHistoryTurn[]
/**
* 语料边界(搜索范围)。由 UI 决定;Runtime 负责把它传给 Engine 并在 prompt 里说明,
* 但**强制**发生在 Engine(target 越界 → 可修正的 invalid_tool_arguments)。
* 不传则不限制范围、不加范围说明。
*/
conversationScope?: QueryAgentConversationScope
/**
* 真实进度回调(ADDITIVE)。由 Adapter 转发给 UI;不传则零额外开销。
* 回调抛出的异常不会影响查询本身。
*/
onProgress?: (event: QueryAgentProgressEvent) => void
}
/** 给模型的范围说明:只描述边界,不做"请遵守"的祈祷式约束(约束由 Engine 强制)。 */
function conversationScopeNote(input: QueryAgentConversationScope): string {
const { scope } = input
const label = input.label?.trim()
const header = label ? `当前搜索范围(由应用界面决定):${label}。` : '当前搜索范围由应用界面决定。'
const rules = [
'所有工具调用都会被强制限制在这个范围内;target 若不在范围内会被拒绝,被拒绝时请如实说明范围限制,不要试图绕过。',
'范围之外还有别的会话,但你**看不到**它们,也不要在回答里声称它们的情况。'
]
if (scope.kind === 'groups') {
rules.push(
'范围是群聊专属:包含全部群会话以及群成员实际发送的消息。问"谁聊过某话题"时应省略 search_messages 的 target 做跨群检索,并在回答里保留群名与发送者。'
)
} else if (scope.kind === 'contact' || scope.kind === 'current') {
rules.push(
'范围只有一个会话:所有工具都可以省略 target(query_messages 也可以),省略即在该会话内检索;不要猜会话名,也不要指定其他会话。'
)
}
return `${header}\n${rules.map((rule) => `- ${rule}`).join('\n')}`
}
/**
* 证据收集器(Host 侧)。
*
* 从 Tool Result 中提取**真实**证据供 UI 展示 —— UI 不允许从回答文本里反解析证据。
* - 按 `messageRef` 去重(search 与 context 命中同一条消息只显示一次);
* - 顺序 = 首次命中顺序;上限 MAX_EVIDENCE_ITEMS;
* - 只保留展示字段,不携带 wxid / md5 / DB id / raw Tool JSON。
*/
class EvidenceCollector {
private readonly items = new Map<string, QueryAgentEvidenceItem>()
addFromToolResult(toolName: string, result: QueryAgentToolResult): void {
const target = result.target && typeof result.target === 'object' ? (result.target as Record<string, unknown>) : undefined
const defaults: { conversationName?: string; conversationType?: 'user' | 'group' } = {
conversationName: typeof target?.displayName === 'string' ? target.displayName : undefined,
conversationType: target?.type === 'user' || target?.type === 'group' ? target.type : undefined
}
const push = (value: unknown): void => {
const item = this.normalize(value, toolName, defaults)
if (item) this.merge(item)
}
if (Array.isArray(result.messages)) result.messages.forEach(push)
if (Array.isArray(result.evidence)) result.evidence.forEach(push)
// message_context:只收 anchor(被补充语境的那条证据),前后文不是本次结论的依据。
if (result.anchor) push(result.anchor)
// recall_hint 自动扩大的那次查询也是真实证据。
const fallback = result.fallbackLookup && typeof result.fallbackLookup === 'object' ? (result.fallbackLookup as Record<string, unknown>) : undefined
if (Array.isArray(fallback?.messages)) fallback.messages.forEach(push)
}
list(): QueryAgentEvidenceItem[] {
return Array.from(this.items.values()).slice(0, MAX_EVIDENCE_ITEMS)
}
private merge(item: QueryAgentEvidenceItem): void {
const existing = this.items.get(item.messageRef)
if (!existing) {
this.items.set(item.messageRef, item)
return
}
// 同一消息被不同 Tool 命中:补齐缺失字段,保留首次的 source。
const merged = existing as unknown as Record<string, unknown>
for (const key of Object.keys(item)) {
if (key === 'source') continue
const value = (item as unknown as Record<string, unknown>)[key]
if (value !== undefined && merged[key] === undefined) merged[key] = value
}
}
private normalize(
value: unknown,
source: string,
defaults: { conversationName?: string; conversationType?: 'user' | 'group' }
): QueryAgentEvidenceItem | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const record = value as Record<string, unknown>
const messageRef = typeof record.messageRef === 'string' ? record.messageRef : undefined
if (!messageRef) return undefined
const attachment =
record.attachment && typeof record.attachment === 'object' && !Array.isArray(record.attachment)
? (record.attachment as Record<string, unknown>)
: undefined
const attachmentView = attachment
? {
...(typeof attachment.kind === 'string' ? { kind: attachment.kind } : {}),
...(typeof attachment.name === 'string' ? { name: attachment.name } : {}),
...(typeof attachment.url === 'string' ? { url: attachment.url } : {}),
...(typeof attachment.sizeBytes === 'number' ? { sizeBytes: attachment.sizeBytes } : {})
}
: undefined
return {
messageRef,
...(typeof record.conversationName === 'string'
? { conversationName: record.conversationName }
: defaults.conversationName
? { conversationName: defaults.conversationName }
: {}),
...(record.conversationType === 'user' || record.conversationType === 'group'
? { conversationType: record.conversationType }
: defaults.conversationType
? { conversationType: defaults.conversationType }
: {}),
...(typeof record.sender === 'string' ? { sender: record.sender } : {}),
...(typeof record.timestamp === 'number' ? { timestamp: record.timestamp } : {}),
...(typeof record.messageType === 'string'
? { messageType: record.messageType }
: typeof record.sourceKind === 'string'
? { messageType: record.sourceKind }
: {}),
...(typeof record.text === 'string' && record.text ? { text: record.text } : {}),
...(attachmentView && Object.keys(attachmentView).length ? { attachment: attachmentView } : {}),
source
}
}
}
export class QueryAgentService {
constructor(
private readonly provider: QueryAgentProvider,
private readonly executeTool: QueryAgentToolExecutor,
private readonly nowProvider: () => Date = () => new Date()
) {}
async run(question: string, options: QueryAgentRunOptions = {}): Promise<QueryAgentResult> {
const startedAt = Date.now()
// 计数用可变持有者:`completed` 由 finally 发出,那时 result 已经离开作用域。
const counters = { modelCallCount: 0, toolCallCount: 0 }
const emit = (stage: QueryAgentProgressStage, extra: { toolName?: string } = {}): void => {
const onProgress = options.onProgress
if (!onProgress) return
const at = Date.now()
try {
onProgress({
stage,
elapsedMs: at - startedAt,
at,
...(extra.toolName ? { toolName: extra.toolName } : {}),
modelCallCount: counters.modelCallCount,
toolCallCount: counters.toolCallCount
})
} catch {
// 进度上报失败绝不能影响查询本身。
}
}
try {
return await this.runLoop(question, options, emit, counters)
} finally {
// 成功、Provider 失败、tool_limit、异常 —— 一律以 completed 收尾,
// 避免 UI 永远停在某个中间阶段。
emit('completed')
}
}
private async runLoop(
question: string,
options: QueryAgentRunOptions,
emit: (stage: QueryAgentProgressStage, extra?: { toolName?: string }) => void,
counters: { modelCallCount: number; toolCallCount: number }
): Promise<QueryAgentResult> {
const trimmed = question.trim()
const startedAt = Date.now()
const runtime = this.provider.getRuntimeConfig()
const result: QueryAgentResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [], modelDurationsMs: [], modelDiagnostics: [] }
if (!trimmed) return { ...result, error: '请输入查询问题', errorKind: 'invalid_question', totalMs: Date.now() - startedAt }
if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', errorKind: 'provider_unavailable', totalMs: Date.now() - startedAt }
const history = options.history || []
const scopeNote = options.conversationScope ? conversationScopeNote(options.conversationScope) : undefined
const messages: Array<Record<string, unknown>> = [
{ role: 'system', content: SYSTEM_PROMPT },
// 范围说明是**上下文**,不是强制执行手段:真正的边界由 Engine 拒绝越界 target 来保证。
...(scopeNote ? [{ role: 'system', content: scopeNote }] : []),
...history.flatMap((turn) => [
{ role: 'user', content: turn.question },
{ role: 'assistant', content: turn.answer }
]),
{ role: 'user', content: trimmed }
]
const toolContext: QueryAgentToolContext = options.conversationScope
? { conversationScope: options.conversationScope.scope }
: {}
const evidence = new EvidenceCollector()
let tools = toolDefinitions()
const retry = newRetryState()
const now = this.nowProvider()
let firstModelAt: number | undefined
let finalModelDuration: number | undefined
while (result.toolCallCount < MAX_TOOL_CALLS) {
// 真实生命周期边界:还没有任何 Tool 结果 → 这次模型调用是"理解问题";
// 已经有结果 → 这次是在消化证据并**生成回答**。不用定时器、不猜进度。
emit(result.toolCallCount === 0 ? 'understanding' : 'generating_answer')
const modelStartedAt = Date.now()
const model = await this.provider.chatWithTools(messages, tools)
result.modelCallCount += 1
const modelDuration = Date.now() - modelStartedAt
// 无论成功失败都记录本次调用耗时与请求级诊断,便于区分模型慢 / 工具慢 / 上游错误。
result.modelDurationsMs.push(modelDuration)
result.modelDiagnostics.push({
index: result.modelCallCount,
elapsedMs: typeof model.elapsedMs === 'number' ? model.elapsedMs : modelDuration,
...(model.errorStatus !== undefined ? { status: model.errorStatus } : {}),
...(model.errorContentType ? { contentType: model.errorContentType } : {}),
...(model.timedOut ? { timedOut: true } : {}),
...(model.htmlInsteadOfJson ? { htmlInsteadOfJson: true } : {}),
...(model.errorCode ? { errorCode: model.errorCode } : {}),
...(model.errorType ? { errorType: model.errorType } : {}),
...(model.success ? {} : { error: model.error || '模型调用失败' })
})
if (firstModelAt === undefined) firstModelAt = Date.now()
if (!model.success) return { ...result, error: model.error || '模型调用失败', errorKind: 'provider_failure', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt }
const calls = model.toolCalls || []
if (calls.length === 0) {
finalModelDuration = modelDuration
result.answer = model.data?.trim() || '模型未返回答案'
result.firstModelMs = firstModelAt - startedAt
result.finalModelMs = finalModelDuration
result.totalMs = Date.now() - startedAt
return result
}
if (result.toolCallCount + calls.length > MAX_TOOL_CALLS) {
return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt }
}
messages.push({ role: 'assistant', content: model.data || '', tool_calls: calls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } })) })
for (const call of calls) {
const inputStartedAt = Date.now()
let toolResult: QueryAgentToolResult | undefined
let traceInput: Record<string, unknown> = {}
let temporalBasis: CanonicalTemporalBasis | undefined
let autoFallback: QueryAgentTraceItem['autoFallback']
try {
if (!tools.some((tool) => tool.function.name === call.name)) {
toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'tool_availability', expected: tools.map((tool) => tool.function.name), actual: call.name }
}
if (!toolResult) {
let parsed: unknown
try { parsed = JSON.parse(call.arguments || '{}') } catch {
toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'json' }
}
if (!toolResult) {
const validated = validateToolArguments(call.name, parsed, now, trimmed)
if (validated.error) {
toolResult = validated.error
} else {
traceInput = validated.input || {}
temporalBasis = validated.temporalBasis
// constraint 时间边界由 Host 结构性锁定:不依赖 prompt,也不静默改写用户问题。
const constraintViolation = lockConstraintTimeRange(call.name, temporalBasis, traceInput, retry)
if (constraintViolation) {
toolResult = constraintViolation
} else if (duplicateRetry(call.name, traceInput, retry)) {
// 明确拒绝“换关键词重搜”里的 identical retry,让模型改用实质不同的条件。
toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'duplicate_retry', expected: '与上一次实质不同的条件', actual: '与上一次完全相同的条件' }
} else {
recordAttempt(call.name, traceInput, retry)
// Tool 真正开始执行 = "在搜索聊天记录"。跨会话范围会明显更慢,
// UI 用范围(不是调用次数)决定副提示文案。
emit('searching', { toolName: call.name })
const primary = await this.executeTool(call.name, traceInput, toolContext)
toolResult = primary
// recall_hint + 有界范围 + 0 结果 → Host 自动做一次“全部历史”corrective lookup。
// 这是一次本地 Query API 调用:不增加 LLM 往返,也不占用 MAX_TOOL_CALLS。
// 注意:自动补查必须沿用同一个语料边界,不能借它逃出当前搜索范围。
if (shouldAutoBroaden(call.name, temporalBasis, traceInput, primary)) {
const fallbackStartedAt = Date.now()
const fallbackResult = await this.executeTool('query_messages', { ...traceInput, timeRange: { kind: 'all' } }, toolContext)
const fallbackCounts = resultCount(fallbackResult)
autoFallback = { reason: AUTO_FALLBACK_REASON, timeRange: { kind: 'all' }, status: fallbackResult.status, durationMs: Date.now() - fallbackStartedAt, ...fallbackCounts }
toolResult = {
...primary,
fallbackLookup: {
reason: AUTO_FALLBACK_REASON,
explanation: '你的 temporalBasis.kind=recall_hint 表示该时间范围只是你推断的回忆线索,并非用户给出的硬边界;首次查询为 0 条,系统已自动在全部历史中再查一次。请分别说明这两个范围的结果。',
timeRange: { kind: 'all' },
status: fallbackResult.status,
resolvedTimeRange: fallbackResult.resolvedTimeRange,
coverage: fallbackResult.coverage,
returnedCount: fallbackResult.returnedCount,
messages: fallbackResult.messages
}
}
}
}
}
}
}
} catch (error) {
if (!toolResult) toolResult = { status: 'invalid_request', error: error instanceof Error ? error.message : '工具调用失败' }
}
const completedToolResult = toolResult || { status: 'invalid_request', error: '工具调用失败' }
const durationMs = Date.now() - inputStartedAt
result.toolCallCount += 1
counters.toolCallCount = result.toolCallCount
result.toolTotalMs += durationMs
// Tool Result 已经拿到 → 真实进入"整理证据"阶段。
emit('organizing_evidence', { toolName: call.name })
// 收集真实证据(去重、限量),供 UI 展示;不进入模型上下文。
evidence.addFromToolResult(call.name, completedToolResult)
result.evidence = evidence.list()
const counts = resultCount(completedToolResult)
// 引擎耗时分解留在 Host 侧(诊断 / UI),不进入模型上下文。
const rawTimings = completedToolResult.timings
const searchTimings: QuerySearchTimings | undefined =
rawTimings && typeof rawTimings === 'object' && !Array.isArray(rawTimings)
? (rawTimings as QuerySearchTimings)
: undefined
result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}), ...(searchTimings ? { searchTimings } : {}) })
const nextTools = completedToolResult.constraint === 'tool_availability'
? tools
: nextToolDefinitions(call.name, completedToolResult, retry, rangeKind(traceInput) === 'all')
const note = retryNote(call.name, completedToolResult, retry)
messages.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: JSON.stringify(toolResultForModel(call.name, completedToolResult, result.toolCallCount, nextTools, note)) })
tools = nextTools
}
}
result.firstModelMs = firstModelAt ? firstModelAt - startedAt : undefined
result.totalMs = Date.now() - startedAt
return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit' }
}
}
@@ -0,0 +1,443 @@
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
import type {
ScheduledReportApiCreateRequest,
ScheduledReportApiExecution,
ScheduledReportApiGroupRef,
ScheduledReportApiTargetRef,
ScheduledReportApiTask,
ScheduledReportApiUpdateRequest
} from '../../shared/scheduled-report-api'
import type {
ScheduledReportExecution,
ScheduledReportRange,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../shared/scheduled-report'
import type { FormattedContact } from './chat-service'
import { resolveContact } from './contact-resolution-service'
import type { ScheduledReportService } from './scheduled-report-service'
export interface ScheduledReportApiDependencies {
service: Pick<
ScheduledReportService,
| 'listTasks'
| 'listExecutions'
| 'createTask'
| 'updateTask'
| 'deleteTask'
| 'setTaskEnabled'
| 'runScheduledReportNow'
> &
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
getCapability: () => Promise<PersonalWechatSendCapability>
listContacts: () => FormattedContact[]
isDatabaseReady: () => boolean
platform?: NodeJS.Platform
}
export class ScheduledReportApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown
) {
super(message)
this.name = 'ScheduledReportApiError'
}
}
const REPORT_RANGES = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const asTrimmed = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
function publicCapability(capability: PersonalWechatSendCapability): Record<string, unknown> {
return {
supported: capability.supported,
ready: capability.ready,
status: capability.status,
capabilities: capability.capabilities,
message: capability.message,
...(capability.error ? { error: capability.error } : {})
}
}
export class ScheduledReportApiService {
private mutationQueue: Promise<void> = Promise.resolve()
constructor(private readonly deps: ScheduledReportApiDependencies) {}
async getCapability(): Promise<Record<string, unknown>> {
try {
return publicCapability(this.applyPlatformCapability(await this.deps.getCapability()))
} catch (error) {
throw new ScheduledReportApiError(
503,
'wechat_capability_unavailable',
'微信发送能力检测失败,请在 TraceMemo 设置中检查个人微信。',
error instanceof Error ? error.message : String(error)
)
}
}
async list(): Promise<ScheduledReportApiTask[]> {
this.assertDatabaseReady()
return (await this.deps.service.listTasks()).map((task) => this.toPublicTask(task))
}
async get(taskId: string): Promise<ScheduledReportApiTask> {
this.assertDatabaseReady()
const task = await this.findTask(taskId)
if (!task) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务')
return this.toPublicTask(task)
}
async create(request: ScheduledReportApiCreateRequest): Promise<ScheduledReportApiTask> {
return this.withMutationLock(() => this.createUnlocked(request))
}
private async createUnlocked(
request: ScheduledReportApiCreateRequest
): Promise<ScheduledReportApiTask> {
this.assertDatabaseReady()
if (!isRecord(request))
throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效')
const groupRef = request.group
if (!(typeof groupRef === 'string' || isRecord(groupRef))) {
throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker')
}
const group = this.resolveGroup(groupRef as ScheduledReportApiGroupRef | string)
const targetRef = request.target
if (targetRef !== undefined && !isRecord(targetRef)) {
throw new ScheduledReportApiError(400, 'invalid_target', 'target 格式无效')
}
const target = this.resolveTarget(targetRef as ScheduledReportApiTargetRef | undefined, group)
const schedule = this.parseSchedule(request.schedule)
const reportRange = this.parseRange(request.reportRange)
const name = asTrimmed(request.name) || `${group.m_nsNickName} · 每日日报`
const enabled = request.enabled !== false
const duplicate = await this.findDuplicate({
group: group.md5,
target: target.m_nsUsrName,
scheduleTime: schedule.time,
reportRange,
enabled
})
if (duplicate) {
throw new ScheduledReportApiError(409, 'duplicate', '相同的定时日报任务已经存在', {
existingTaskId: duplicate.id,
task: this.toPublicTask(duplicate)
})
}
const result = await this.deps.service.createTask({
name,
group: group.md5,
target: target.m_nsUsrName,
scheduleTime: schedule.time,
reportRange,
enabled
})
if (!result.success || !result.data) {
throw new ScheduledReportApiError(400, 'invalid_request', result.error || '定时日报创建失败')
}
return this.toPublicTask(result.data)
}
async update(
taskId: string,
request: ScheduledReportApiUpdateRequest
): Promise<ScheduledReportApiTask> {
return this.withMutationLock(() => this.updateUnlocked(taskId, request))
}
private async updateUnlocked(
taskId: string,
request: ScheduledReportApiUpdateRequest
): Promise<ScheduledReportApiTask> {
this.assertDatabaseReady()
const current = await this.findTask(taskId)
if (!current) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务')
if (!isRecord(request))
throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效')
const currentGroup = this.contactForTask(current)
let group = currentGroup
if (request.group !== undefined) {
if (!(typeof request.group === 'string' || isRecord(request.group))) {
throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker')
}
group = this.resolveGroup(request.group as ScheduledReportApiGroupRef | string)
}
if (request.target !== undefined && !isRecord(request.target)) {
throw new ScheduledReportApiError(400, 'invalid_target', 'target 格式无效')
}
const target =
request.target === undefined
? group
: this.resolveTarget(request.target as unknown as ScheduledReportApiTargetRef, group)
const schedule =
request.schedule === undefined
? { type: 'daily' as const, time: current.scheduleTime }
: this.parseSchedule(request.schedule)
const reportRange =
request.reportRange === undefined ? current.reportRange : this.parseRange(request.reportRange)
const enabled = request.enabled === undefined ? current.enabled : Boolean(request.enabled)
const name = request.name === undefined ? current.name : asTrimmed(request.name)
if (!name) throw new ScheduledReportApiError(400, 'invalid_request', '日报名称不能为空')
const duplicate = await this.findDuplicate(
{
group: group.md5,
target: target.m_nsUsrName,
scheduleTime: schedule.time,
reportRange,
enabled
},
taskId
)
if (duplicate) {
throw new ScheduledReportApiError(409, 'duplicate', '相同的定时日报任务已经存在', {
existingTaskId: duplicate.id,
task: this.toPublicTask(duplicate)
})
}
const patch: ScheduledReportUpdateInput = {
name,
group: group.md5,
target: target.m_nsUsrName,
scheduleTime: schedule.time,
reportRange,
enabled
}
const result = await this.deps.service.updateTask(taskId, patch)
if (!result.success || !result.data) {
throw new ScheduledReportApiError(400, 'invalid_request', result.error || '定时日报更新失败')
}
return this.toPublicTask(result.data)
}
async delete(taskId: string): Promise<{ deletedId: string }> {
this.assertDatabaseReady()
const result = await this.deps.service.deleteTask(taskId)
if (!result.success || !result.data) {
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务')
}
return result.data
}
async setEnabled(taskId: string, enabled: boolean): Promise<ScheduledReportApiTask> {
this.assertDatabaseReady()
const result = await this.deps.service.setTaskEnabled(taskId, enabled)
if (!result.success || !result.data) {
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务')
}
return this.toPublicTask(result.data)
}
async run(taskId: string): Promise<ScheduledReportExecution> {
this.assertDatabaseReady()
const result = await this.deps.service.runScheduledReportNow(taskId)
if (!result.data) {
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务')
}
return result.data
}
async retrySend(executionId: string): Promise<ScheduledReportExecution> {
const retry = this.deps.service.retryScheduledReportSend
if (!retry) {
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
}
const result = await retry.call(this.deps.service, executionId)
if (!result.data) {
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
}
return result.data
}
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
this.assertDatabaseReady()
const task = await this.findTask(taskId)
if (!task) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务')
return (await this.deps.service.listExecutions(taskId)).map((execution) => ({ ...execution }))
}
private assertDatabaseReady(): void {
if (!this.deps.isDatabaseReady()) {
throw new ScheduledReportApiError(503, 'database_not_ready', 'TraceMemo 数据库未初始化')
}
}
private async withMutationLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = this.mutationQueue
let release!: () => void
this.mutationQueue = new Promise<void>((resolve) => {
release = resolve
})
await previous
try {
return await operation()
} finally {
release()
}
}
private applyPlatformCapability(
capability: PersonalWechatSendCapability
): PersonalWechatSendCapability {
const platform = this.deps.platform || process.platform
if (platform === 'darwin' || platform === 'win32') return capability
return {
...capability,
supported: false,
ready: false,
status: 'unsupported',
capabilities: { text: false, image: false, voice: false },
message: '微信消息发送目前仅支持 macOS 和 Windows'
}
}
private async findTask(taskId: string): Promise<ScheduledReportTask | undefined> {
const normalized = asTrimmed(taskId)
if (!normalized) return undefined
return (await this.deps.service.listTasks()).find((task) => task.id === normalized)
}
private resolveGroup(ref: ScheduledReportApiGroupRef | string): FormattedContact {
const contacts = this.deps.listContacts().filter((contact) => contact.type === 'group')
const query = typeof ref === 'string' ? ref : isRecord(ref) ? asTrimmed(ref.name) : ''
const talker = typeof ref === 'string' ? '' : isRecord(ref) ? asTrimmed(ref.talker) : ''
if (!query && !talker) {
throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker')
}
if (talker) {
const direct = contacts.find(
(contact) => contact.m_nsUsrName === talker || contact.md5 === talker
)
if (direct) return direct
throw new ScheduledReportApiError(404, 'group_not_found', '未找到 talker 对应的唯一群聊')
}
const resolution = resolveContact(query || talker, contacts, 'group')
if (resolution.ambiguous) {
throw new ScheduledReportApiError(
409,
'group_ambiguous',
'群聊名称匹配到多个群,请先确认目标群聊',
{
candidates: resolution.candidates
}
)
}
if (!resolution.matched || !resolution.conversationId) {
throw new ScheduledReportApiError(404, 'group_not_found', '未找到唯一的目标群聊')
}
const contact = contacts.find((item) => item.md5 === resolution.conversationId)
if (!contact) throw new ScheduledReportApiError(404, 'group_not_found', '未找到唯一的目标群聊')
return contact
}
private resolveTarget(
ref: ScheduledReportApiTargetRef | undefined,
group: FormattedContact
): FormattedContact {
if (!ref) return group
if (!isRecord(ref) || ref.type !== 'wechat_group') {
throw new ScheduledReportApiError(400, 'invalid_target', 'target.type 必须是 wechat_group')
}
const talker = asTrimmed(ref.talker)
const name = asTrimmed(ref.name)
const target = this.resolveGroup({ talker, name })
if (target.md5 !== group.md5) {
throw new ScheduledReportApiError(400, 'target_mismatch', '发送目标必须是选择的微信群')
}
return target
}
private parseSchedule(value: unknown): { type: 'daily'; time: string } {
if (!isRecord(value) || value.type !== 'daily' || !asTrimmed(value.time)) {
throw new ScheduledReportApiError(
400,
'invalid_schedule',
'schedule 目前只支持 { type: "daily", time: "HH:mm" }'
)
}
const time = asTrimmed(value.time)
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time)) {
throw new ScheduledReportApiError(400, 'invalid_schedule', 'schedule.time 必须是 HH:mm')
}
return { type: 'daily', time }
}
private parseRange(value: unknown): ScheduledReportRange {
const range = value === undefined ? 'yesterday' : value
if (typeof range !== 'string' || !REPORT_RANGES.has(range as ScheduledReportRange)) {
throw new ScheduledReportApiError(400, 'invalid_report_range', 'reportRange 不受支持')
}
return range as ScheduledReportRange
}
private contactForTask(task: ScheduledReportTask): FormattedContact {
const contacts = this.deps.listContacts().filter((contact) => contact.type === 'group')
const contact = contacts.find(
(item) =>
item.md5 === task.group ||
item.m_nsUsrName === task.target ||
item.m_nsUsrName === task.group
)
if (contact) return contact
return {
md5: task.group,
m_nsUsrName: task.target,
m_nsNickName: task.group,
type: 'group'
}
}
private async findDuplicate(
candidate: {
group: string
target: string
scheduleTime: string
reportRange: ScheduledReportRange
enabled: boolean
},
excludeTaskId?: string
): Promise<ScheduledReportTask | undefined> {
const tasks = await this.deps.service.listTasks()
return tasks.find(
(task) =>
task.id !== excludeTaskId &&
task.group === candidate.group &&
task.target === candidate.target &&
task.scheduleTime === candidate.scheduleTime &&
task.reportRange === candidate.reportRange &&
task.enabled === candidate.enabled
)
}
private toPublicTask(task: ScheduledReportTask): ScheduledReportApiTask {
const group = this.contactForTask(task)
const talker = task.target || group.m_nsUsrName
const name = group.m_nsNickName || task.group
return {
id: task.id,
name: task.name,
group: { talker, name },
schedule: { type: 'daily', time: task.scheduleTime },
reportRange: task.reportRange,
target: { type: 'wechat_group', talker, name },
enabled: task.enabled,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
...(task.lastRunAt ? { lastRunAt: task.lastRunAt } : {}),
nextRunAt: task.nextRunAt
}
}
}
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -39,6 +39,15 @@ export interface AppSettings {
showStartupProgress: boolean showStartupProgress: boolean
ttsSelectedVoiceId: string ttsSelectedVoiceId: string
ttsModel: TextToSpeechModel ttsModel: TextToSpeechModel
/** Keep a running personal-WeChat OneBot process across app restarts. */
keepPersonalWechatProcess?: boolean
windowsWechatPort: string
/**
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
* 不在用户界面暴露实验性名称)。
*/
queryAgentEnabled: boolean
} }
function getDefaultDbRoot(): string { function getDefaultDbRoot(): string {
@@ -120,7 +129,10 @@ const DEFAULT_SETTINGS: AppSettings = {
compactMode: false, compactMode: false,
showStartupProgress: true, showStartupProgress: true,
ttsSelectedVoiceId: '', ttsSelectedVoiceId: '',
ttsModel: 's2.1-pro-free' ttsModel: 's2.1-pro-free',
keepPersonalWechatProcess: false,
windowsWechatPort: '',
queryAgentEnabled: true
} }
const SETTINGS_FILE = path.join( const SETTINGS_FILE = path.join(
@@ -162,6 +174,12 @@ export function loadSettings(): AppSettings {
if (cache.dbRoot) { if (cache.dbRoot) {
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot) cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
} }
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
if (cache.recallProtectionEnabled) {
cache.recallProtectionEnabled = false
saveSettings(cache)
}
return cache return cache
} }
} catch (error) { } catch (error) {
@@ -172,7 +190,9 @@ export function loadSettings(): AppSettings {
} }
export function saveSettings(next: AppSettings): AppSettings { export function saveSettings(next: AppSettings): AppSettings {
cache = { ...next } // 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
// 避免遗留入口或旧版本把它重新打开。
cache = { ...next, recallProtectionEnabled: false }
try { try {
ensureDir() ensureDir()
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 }) fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
+636
View File
@@ -0,0 +1,636 @@
import { app } from 'electron'
import { createHash, randomUUID } from 'crypto'
import fs from 'fs-extra'
import path from 'path'
import type {
PersonalWechatSendCapability,
PersonalWechatSendRequest,
PersonalWechatSendResult
} from '../../shared/personal-wechat'
import type {
PolicyDecision,
WechatActionAuditRecord,
WechatActionContent,
WechatActionErrorCode,
WechatActionMemberEventReference,
WechatActionRequest,
WechatActionResult
} from '../../shared/wechat-action'
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
import { personalWechatSendService } from './personal-wechat-send-service'
const MAX_AUDIT_RECORDS = 500
const MAX_CONTENT_PREVIEW_LENGTH = 240
export const AUTOMATION_SEND_INTERVAL_MS = 3_000
const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification'])
export interface WechatActionGatewayDependencies {
getCapability?: () => Promise<PersonalWechatSendCapability>
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
getMemberEvent?: (
sourceId: string
) =>
| WechatActionMemberEventReference
| Promise<WechatActionMemberEventReference | undefined>
| undefined
getUserDataPath?: () => string
now?: () => Date
wait?: (milliseconds: number) => Promise<void>
}
interface LoadedAuditState {
path: string
records: WechatActionAuditRecord[]
}
export interface WechatActionPolicyContext {
memberEvent?: WechatActionMemberEventReference
}
const defaultDependencies = (): Required<
Pick<
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
> => ({
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
send: (request) => personalWechatSendService.send(request),
getUserDataPath: () => app.getPath('userData'),
now: () => new Date(),
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
})
/**
* 统一管理微信发送操作,在真正发送前完成必要检查;
* 具体发送由底层服务处理,使用者只需提供发送内容和对象。
*/
export class WechatActionGateway {
private readonly deps: Required<
Pick<
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
> &
Omit<
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
private readonly memberEvents = new Map<string, WechatActionMemberEventReference>()
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
private auditState: LoadedAuditState | null = null
private automationSendTail: Promise<void> = Promise.resolve()
private lastAutomationSendStartedAt: number | undefined
constructor(deps: WechatActionGatewayDependencies = {}) {
this.deps = { ...defaultDependencies(), ...deps }
}
/** 记录退群事件,发送通知前可确认事件所属群聊。 */
registerMemberEvent(event: WechatActionMemberEventReference): void {
const id = String(event?.id || '').trim()
const roomId = String(event?.roomId || '').trim()
if (!id || !roomId) return
this.memberEvents.set(id, { id, roomId })
}
registerMemberEvents(events: WechatActionMemberEventReference[]): void {
for (const event of events) this.registerMemberEvent(event)
}
clearMemberEvents(): void {
this.memberEvents.clear()
}
listAuditRecords(): WechatActionAuditRecord[] {
return this.loadAuditState().records.map((record) => ({ ...record }))
}
async execute(request: WechatActionRequest): Promise<WechatActionResult> {
const normalized = normalizeActionRequest(request)
const idempotencyKey = normalized ? this.idempotencyKey(normalized) : undefined
if (idempotencyKey) {
const existing = await this.findExisting(idempotencyKey)
if (existing) return existing
const running = this.inFlight.get(idempotencyKey)
if (running) return running
}
const promise = this.executeOnce(request, normalized, idempotencyKey)
if (idempotencyKey) this.inFlight.set(idempotencyKey, promise)
try {
return await promise
} finally {
if (idempotencyKey && this.inFlight.get(idempotencyKey) === promise) {
this.inFlight.delete(idempotencyKey)
}
}
}
private async executeOnce(
originalRequest: WechatActionRequest,
request: WechatActionRequest | null,
idempotencyKey?: string
): Promise<WechatActionResult> {
const startedAt = this.deps.now().toISOString()
const actionId = String(originalRequest?.id || '').trim() || randomUUID()
if (!request) {
return this.finishBlocked(
actionId,
originalRequest,
idempotencyKey,
startedAt,
validationErrorCode(originalRequest),
validationErrorReason(originalRequest)
)
}
const eventContext = await this.resolveEventContext(request)
const policy = evaluateWechatActionPolicy(request, eventContext)
if (policy.decision !== 'allow') {
return this.finishBlocked(
actionId,
request,
idempotencyKey,
startedAt,
policy.reasonCode || 'POLICY_BLOCKED',
policy.reason || '该发送动作未通过策略检查'
)
}
const capabilityType = request.content.type
let capability: PersonalWechatSendCapability | undefined
try {
capability = await this.deps.getCapability()
} catch (error) {
return this.finishFailed(
actionId,
request,
idempotencyKey,
startedAt,
'SEND_CAPABILITY_UNAVAILABLE',
error instanceof Error ? error.message : String(error)
)
}
if (!capability || !capability.ready) {
return this.finishFailed(
actionId,
request,
idempotencyKey,
startedAt,
'SEND_CAPABILITY_UNAVAILABLE',
capability?.error || capability?.message || '个人微信发送能力不可用'
)
}
if (!capability.capabilities?.[capabilityType]) {
return this.finishFailed(
actionId,
request,
idempotencyKey,
startedAt,
'SEND_NOT_READY',
capability.message || `个人微信尚未准备好发送${contentTypeLabel(capabilityType)}`
)
}
let sendResult: PersonalWechatSendResult
try {
sendResult = await this.send(request)
} catch (error) {
return this.finishFailed(
actionId,
request,
idempotencyKey,
startedAt,
'SEND_FAILED',
error instanceof Error ? error.message : String(error)
)
}
if (!sendResult.success) {
return this.finishFailed(
actionId,
request,
idempotencyKey,
startedAt,
'SEND_FAILED',
sendResult.error || '微信发送失败',
sendResult
)
}
return this.finishSent(actionId, request, idempotencyKey, startedAt, sendResult)
}
private send(request: WechatActionRequest): Promise<PersonalWechatSendResult> {
const sendRequest = toPersonalWechatSendRequest(request)
if (request.triggerType !== 'automation') return this.deps.send(sendRequest)
const queued = this.automationSendTail.then(async () => {
const now = this.deps.now().getTime()
const remaining = this.lastAutomationSendStartedAt !== undefined
? Math.max(0, AUTOMATION_SEND_INTERVAL_MS - (now - this.lastAutomationSendStartedAt))
: 0
if (remaining > 0) await this.deps.wait(remaining)
this.lastAutomationSendStartedAt = this.deps.now().getTime()
return this.deps.send(sendRequest)
})
this.automationSendTail = queued.then(
() => undefined,
() => undefined
)
return queued
}
private async resolveEventContext(
request: WechatActionRequest
): Promise<WechatActionPolicyContext> {
if (request.purpose !== 'member_left_notification') return {}
const sourceId = String(request.sourceId || '').trim()
if (!sourceId) return {}
let memberEvent: WechatActionMemberEventReference | undefined = this.memberEvents.get(sourceId)
if (!memberEvent && this.deps.getMemberEvent) {
let resolved: WechatActionMemberEventReference | undefined
try {
resolved = await this.deps.getMemberEvent(sourceId)
} catch {
resolved = undefined
}
if (resolved && String(resolved.id || '').trim() === sourceId) {
memberEvent = { id: String(resolved.id), roomId: String(resolved.roomId) }
this.registerMemberEvent(memberEvent)
}
}
return memberEvent ? { memberEvent } : {}
}
private async findExisting(idempotencyKey: string): Promise<WechatActionResult | undefined> {
const state = this.loadAuditState()
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
if (!existing) return undefined
return {
actionId: existing.actionId,
status: existing.sendStatus,
decision: existing.decision,
...(existing.errorCode ? { errorCode: existing.errorCode } : {}),
...(existing.decisionReason ? { reason: existing.decisionReason } : {}),
startedAt: existing.startedAt,
finishedAt: existing.finishedAt
}
}
private idempotencyKey(request: WechatActionRequest): string | undefined {
const explicit = String(request.idempotencyKey || '').trim()
if (explicit) return explicit
if (request.purpose === 'member_left_notification' && request.sourceId) {
return `member_left_notification:${request.sourceId}`
}
if (request.origin === 'scheduled_report' && request.executionId) {
return `scheduled_report:${request.executionId}`
}
return undefined
}
private finishSent(
actionId: string,
request: WechatActionRequest,
idempotencyKey: string | undefined,
startedAt: string,
sendResult: PersonalWechatSendResult
): WechatActionResult {
const finishedAt = this.deps.now().toISOString()
const result: WechatActionResult = {
actionId,
status: 'sent',
decision: 'allow',
startedAt,
finishedAt,
sendResult
}
this.writeAudit(request, idempotencyKey, result)
return result
}
private finishFailed(
actionId: string,
request: WechatActionRequest,
idempotencyKey: string | undefined,
startedAt: string,
errorCode: WechatActionErrorCode,
reason: string,
sendResult?: PersonalWechatSendResult
): WechatActionResult {
const finishedAt = this.deps.now().toISOString()
const result: WechatActionResult = {
actionId,
status: 'failed',
decision: 'allow',
errorCode,
reason,
startedAt,
finishedAt,
...(sendResult ? { sendResult } : {})
}
this.writeAudit(request, idempotencyKey, result)
return result
}
private finishBlocked(
actionId: string,
request: WechatActionRequest,
idempotencyKey: string | undefined,
startedAt: string,
errorCode: WechatActionErrorCode,
reason: string
): WechatActionResult {
const finishedAt = this.deps.now().toISOString()
const result: WechatActionResult = {
actionId,
status: 'blocked',
decision: 'block',
errorCode,
reason,
startedAt,
finishedAt
}
this.writeAudit(request, idempotencyKey, result)
return result
}
private loadAuditState(): LoadedAuditState {
const filePath = this.auditFilePath()
if (this.auditState?.path === filePath) return this.auditState
let records: WechatActionAuditRecord[] = []
try {
const value = fs.readJsonSync(filePath) as unknown
if (Array.isArray(value)) records = normalizeAuditRecords(value)
else if (
value &&
typeof value === 'object' &&
Array.isArray((value as { actions?: unknown }).actions)
) {
records = normalizeAuditRecords((value as { actions: unknown[] }).actions)
}
} catch {
records = []
}
this.auditState = { path: filePath, records }
return this.auditState
}
private writeAudit(
request: WechatActionRequest,
idempotencyKey: string | undefined,
result: WechatActionResult
): void {
if (
!request ||
!request.recipient ||
(request.recipient.type !== 'group' && request.recipient.type !== 'contact') ||
!request.content ||
(request.content.type !== 'text' &&
request.content.type !== 'image' &&
request.content.type !== 'voice')
) {
return
}
const state = this.loadAuditState()
const record: WechatActionAuditRecord = {
actionId: result.actionId,
...(idempotencyKey ? { idempotencyKey } : {}),
origin: request.origin,
purpose: request.purpose,
triggerType: request.triggerType,
...(request.sourceId ? { sourceId: request.sourceId } : {}),
...(request.executionId ? { executionId: request.executionId } : {}),
recipientType: request.recipient.type,
recipientId: request.recipient.id,
...(request.recipient.name ? { recipientName: request.recipient.name } : {}),
contentType: request.content.type,
...contentAudit(request.content),
createdAt: result.startedAt,
startedAt: result.startedAt,
finishedAt: result.finishedAt,
decision: result.decision,
...(result.reason ? { decisionReason: result.reason } : {}),
sendStatus: result.status,
...(result.errorCode ? { errorCode: result.errorCode } : {})
}
const withoutKey = state.records.filter((item) => item.actionId !== record.actionId)
state.records = [record, ...withoutKey].slice(0, MAX_AUDIT_RECORDS)
try {
fs.ensureDirSync(path.dirname(state.path))
fs.writeJsonSync(state.path, state.records, { spaces: 2 })
} catch (error) {
console.warn('[WechatActionGateway] 保存 Action 审计失败:', error)
}
}
private auditFilePath(): string {
return path.join(this.deps.getUserDataPath(), 'actions', 'wechat-actions.json')
}
}
export function evaluateWechatActionPolicy(
request: WechatActionRequest,
context: WechatActionPolicyContext = {}
): PolicyDecision {
if (!request || typeof request !== 'object') {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'INVALID_REQUEST',
reason: '发送动作请求格式无效'
}
}
const recipient = request.recipient
if (
!recipient ||
typeof recipient !== 'object' ||
(recipient.type !== 'group' && recipient.type !== 'contact') ||
!String(recipient.id || '').trim()
) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'INVALID_RECIPIENT',
reason: '发送动作必须指定有效的收件人'
}
}
if (request.triggerType === 'automation' && !AUTOMATION_PURPOSE_ALLOWLIST.has(request.purpose)) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'ACTION_NOT_ALLOWED',
reason: `自动化动作不允许执行 purpose=${request.purpose}`
}
}
if (request.purpose === 'member_left_notification') {
if (
!request.sourceId ||
!context.memberEvent ||
context.memberEvent.id !== request.sourceId ||
!context.memberEvent.roomId
) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'INVALID_REQUEST',
reason: '退群通知必须关联已记录的退群事件'
}
}
if (request.recipient.type !== 'group' || request.recipient.id !== context.memberEvent.roomId) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'RECIPIENT_SCOPE_VIOLATION',
reason: '退群通知只能发送回原事件所在群聊'
}
}
}
return { decision: 'allow', source: 'deterministic' }
}
/** 发送前会根据固定规则检查是否允许发送。 */
export function shouldUseAiPolicy(request: WechatActionRequest): boolean {
void request
return false
}
export function normalizeActionRequest(value: unknown): WechatActionRequest | null {
if (!value || typeof value !== 'object') return null
const input = value as Partial<WechatActionRequest>
const origin = String(input.origin || '').trim()
const purpose = String(input.purpose || '').trim()
const triggerType = input.triggerType
const recipient = input.recipient
const content = input.content
if (!origin || !purpose || (triggerType !== 'automation' && triggerType !== 'user')) return null
if (!recipient || typeof recipient !== 'object') return null
const recipientType = (recipient as { type?: unknown }).type
const recipientId = String((recipient as { id?: unknown }).id || '').trim()
if (recipientType !== 'group' && recipientType !== 'contact') return null
if (!recipientId) return null
if (!content || typeof content !== 'object') return null
const contentType = (content as { type?: unknown }).type
if (contentType === 'text') {
const text = String((content as { text?: unknown }).text || '').trim()
if (!text || text.length > 2_000) return null
return {
...input,
origin,
purpose,
triggerType,
...(input.id ? { id: String(input.id).trim() } : {}),
...(input.idempotencyKey ? { idempotencyKey: String(input.idempotencyKey).trim() } : {}),
...(input.sourceId ? { sourceId: String(input.sourceId).trim() } : {}),
...(input.executionId ? { executionId: String(input.executionId).trim() } : {}),
recipient: {
type: recipientType,
id: recipientId,
...((recipient as { name?: unknown }).name
? { name: String((recipient as { name?: unknown }).name).trim() }
: {})
},
content: { type: 'text', text }
} as WechatActionRequest
}
if (contentType !== 'image' && contentType !== 'voice') return null
const filePath = String((content as { path?: unknown }).path || '').trim()
if (!filePath) return null
return {
...input,
origin,
purpose,
triggerType,
...(input.id ? { id: String(input.id).trim() } : {}),
...(input.idempotencyKey ? { idempotencyKey: String(input.idempotencyKey).trim() } : {}),
...(input.sourceId ? { sourceId: String(input.sourceId).trim() } : {}),
...(input.executionId ? { executionId: String(input.executionId).trim() } : {}),
recipient: {
type: recipientType,
id: recipientId,
...((recipient as { name?: unknown }).name
? { name: String((recipient as { name?: unknown }).name).trim() }
: {})
},
content: { type: contentType, path: filePath }
} as WechatActionRequest
}
function validationErrorCode(value: unknown): WechatActionErrorCode {
if (!value || typeof value !== 'object') return 'INVALID_REQUEST'
const recipient = (value as { recipient?: unknown }).recipient
if (!recipient || typeof recipient !== 'object') return 'INVALID_RECIPIENT'
const recipientValue = recipient as { type?: unknown; id?: unknown }
if (
(recipientValue.type !== 'group' && recipientValue.type !== 'contact') ||
!String(recipientValue.id || '').trim()
) {
return 'INVALID_RECIPIENT'
}
return 'INVALID_REQUEST'
}
function validationErrorReason(value: unknown): string {
return validationErrorCode(value) === 'INVALID_RECIPIENT'
? '发送动作必须指定有效的收件人'
: '发送动作请求格式无效'
}
function toPersonalWechatSendRequest(request: WechatActionRequest): PersonalWechatSendRequest {
const base = {
to: request.recipient.id,
isGroup: request.recipient.type === 'group'
}
if (request.content.type === 'text') return { ...base, type: 'text', text: request.content.text }
if (request.content.type === 'image') {
return { ...base, type: 'image', filePath: request.content.path }
}
const metadata = request.metadata && typeof request.metadata === 'object' ? request.metadata : {}
const fromId = typeof metadata.fromId === 'string' ? metadata.fromId.trim() : ''
const durationMs =
typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs)
? metadata.durationMs
: undefined
return {
...base,
type: 'voice',
filePath: request.content.path,
...(fromId ? { fromId } : {}),
...(durationMs !== undefined ? { durationMs } : {})
}
}
function contentAudit(
content: WechatActionContent
): Pick<WechatActionAuditRecord, 'contentPreview' | 'contentHash'> {
const raw = content.type === 'text' ? content.text : content.path
const preview = content.type === 'text' ? raw : path.basename(raw)
return {
contentPreview: preview.slice(0, MAX_CONTENT_PREVIEW_LENGTH),
contentHash: createHash('sha256').update(raw).digest('hex')
}
}
function contentTypeLabel(type: WechatActionContent['type']): string {
return type === 'text' ? '文字' : type === 'image' ? '图片' : '语音'
}
function normalizeAuditRecords(values: unknown[]): WechatActionAuditRecord[] {
return values
.filter((value): value is WechatActionAuditRecord => {
if (!value || typeof value !== 'object') return false
const record = value as Partial<WechatActionAuditRecord>
return Boolean(
String(record.actionId || '').trim() &&
String(record.recipientId || '').trim() &&
String(record.contentType || '').trim() &&
String(record.startedAt || '').trim() &&
String(record.finishedAt || '').trim()
)
})
.slice(0, MAX_AUDIT_RECORDS)
}
export const wechatActionGateway = new WechatActionGateway()
/** 兼容性名称,方便已有调用继续使用。 */
export const personalWechatActionService = wechatActionGateway
@@ -0,0 +1,58 @@
import type { ActionLogEntry } from '../../shared/action-log'
import type { Contact } from '../../shared/types'
import type { WechatActionAuditRecord } from '../../shared/wechat-action'
import * as chat from './chat-service'
import { wechatActionGateway } from './wechat-action-gateway'
export interface WechatActionLogServiceDependencies {
listAuditRecords?: () => WechatActionAuditRecord[]
listContacts?: () => Contact[]
}
export class WechatActionLogService {
private readonly listAuditRecords: () => WechatActionAuditRecord[]
private readonly listContacts: () => Contact[]
constructor(deps: WechatActionLogServiceDependencies = {}) {
this.listAuditRecords = deps.listAuditRecords || (() => wechatActionGateway.listAuditRecords())
this.listContacts = deps.listContacts || (() => chat.listContacts())
}
list(): ActionLogEntry[] {
let contacts: Contact[] = []
try {
contacts = this.listContacts()
} catch {
// 数据库未连接时仍可查看历史审计。
}
const names = new Map(
contacts.map((contact) => [
contact.m_nsUsrName,
contact.m_nsNickName?.trim() || contact.remark?.trim() || contact.m_nsUsrName
])
)
return this.listAuditRecords()
.map((record) => ({
id: record.actionId,
category: 'wechat_send' as const,
source: record.origin,
purpose: record.purpose,
...(record.triggerType ? { triggerType: record.triggerType } : {}),
timestamp: record.finishedAt || record.startedAt || record.createdAt,
recipientType: record.recipientType,
recipientId: record.recipientId,
recipientName: record.recipientName || names.get(record.recipientId),
contentType: record.contentType,
contentPreview: record.contentPreview,
status: record.sendStatus,
errorCode: record.errorCode,
reason: record.decisionReason,
...(record.executionId ? { executionId: record.executionId } : {}),
...(record.idempotencyKey ? { idempotencyKey: record.idempotencyKey } : {})
}))
.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))
}
}
export const wechatActionLogService = new WechatActionLogService()

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