Compare commits

..
108 Commits
Author SHA1 Message Date
wuyouMaster 99c6e923bc feat: read-only roominfo (owner/announcement/openim) on GroupSnapshot 2026-09-25 00:06:48 +08:00
wuyouMaster afb876250e feat: merge favorites into export and search_messages 2026-09-24 23:55:52 +08:00
wuyouMaster 19bf497af0 feat: wire favorites read path, AMR ffmpeg decode, voice cache/VoiceTemp walk 2026-09-24 21:27:48 +08:00
wuyouMaster 784bed0ee0 feat: align favorite numeric types with real fav_db_item.type 2026-09-24 21:09:13 +08:00
wuyouMaster 52c56bfc98 feat: map MM_FAV types to message cards and voice disk bypass V2 2026-09-24 20:44:19 +08:00
wuyouMaster e69c3626d6 feat: parse streamvideo/gift cards and heuristic local_type 2/8 2026-09-24 20:06:16 +08:00
wuyouMaster 7864cf001f feat: parse music/subscribe/kefu appmsg cards into read-only share/system 2026-09-24 19:54:24 +08:00
wuyouMaster 264d25b539 feat: parse patMsg/findernamecard/product/friend-verify and unpack local_type 2026-09-24 19:46:40 +08:00
wuyouMaster 62c1b0063f Merge origin/main into local payment export work 2026-09-24 19:25:56 +08:00
wuyouMaster 4aef093802 feat: parse trans_id/fee_type/refund_bank_type into transfer pay info 2026-09-24 19:22:09 +08:00
wuyouMaster 88ee3f19bb feat: map transfer/hb status labels into export and chat cards 2026-09-24 19:01:15 +08:00
qingmao 8503c92f3a Add files via upload 2026-09-22 09:49:09 +08:00
wuyouMaster 95b3ba6121 fix: auto retry scheduled reports after send recovery 2026-09-20 20:40:10 +08:00
wuyouMaster b6d287a299 fix: make packaging checks platform safe 2026-09-20 18:16:41 +08:00
wuyouMaster 4c25236070 fix: ad-hoc sign packaged macOS key helpers and app bundle
electron-builder 26 skips macOS signing entirely when no Developer ID identity is configured, so the packaged key helpers can ship unsigned or with a broken signature and macOS kills them even with SIP disabled. afterPack now verifies and ad-hoc re-signs the packaged xkey helpers and the outer app bundle, failing the build when a signature cannot be repaired.
2026-09-20 18:16:41 +08:00
qingmao c1224cb612 Merge pull request #45 from mmhh256/fix/query-agent-system-messages
fix: 合并 Query Agent 的连续 system messages
2026-09-20 16:11:08 +08:00
Wxw-Gu 5036639aa1 test: 测试用例 2026-09-20 15:56:27 +08:00
Wxw-Gu 9593ca0f54 fix: 修复语音消息取错、时长精度与转写不刷新
- 时长解析:改取 <voicemsg voicelength>(毫秒),不再误取 length(SILK 字节数)
- 时长显示:四舍五入对齐微信口径,有原生时长时不被解码时长覆盖
- 转写:重新识别改为强制重算,跳过身份级缓存短路(音频级缓存仍生效)
- 日报:语音累计秒数显示取整,不再出现小数
2026-09-20 15:40:01 +08:00
mmhh256 b441287d54 fix: 合并 Query Agent 的连续 system messages 2026-09-19 14:01:39 +08:00
Wxw-Gu 9b82d29037 fix: 兼容微信新版系统消息模板格式,入群通知不再显示成「撤销」 2026-09-18 14:41:50 +08:00
Wxw-Gu 0f270366aa feat: 图片文字索引按时间分段优先处理最近图片
- 首次索引先处理最近 7 天,再依次回溯 30 天 / 近一年 / 更早历史
 - 完成后新到的图片单独补齐,不受历史回填影响
 - 覆盖度增加时间维度,可区分「最近已完整」与「更早仍在补齐」
2026-09-18 14:39:05 +08:00
Wxw-Gu 12b8fb34c6 fix: 增加sip文案 2026-09-18 09:38:33 +08:00
Wxw-Gu 774346d503 fix: 日报头像补齐下载重试 2026-09-17 19:24:27 +08:00
Wxw-Gu b97e1f4bc6 fix: 问问微信恢复引用编号,日报修复成员头像与 hero 溢出
- 问问微信:Host 分配稳定 citationId 并注入模型上下文,正文 [E#] 与证据按钮同号
- 问问微信:回答返回前校验引用,幻觉编号移除并提示,不再出现不可信的引用按钮
- Agent Hub:收紧「最近会话」快捷路由,内容查询交回 Query Agent
2026-09-17 18:56:32 +08:00
Wxw-Gu 4b2395a8d2 feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
2026-09-17 17:32:37 +08:00
Wxw-Gu 1337bcb7de feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
2026-09-17 13:11:20 +08:00
电摇小子 b8f08d54c8 feat: 新增微信图片文字索引与问问微信图片检索能力 2026-09-16 10:42:52 +08:00
电摇小子 24399f1d70 feat: 新增 Windows 本地图片文字识别能力 2026-09-16 00:23:23 +08:00
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
447 changed files with 69261 additions and 6918 deletions
+3
View File
@@ -5,6 +5,9 @@ VITE_DB_KEY=
# Set to true/1/yes/on for local development. Default is disabled.
VITE_AUTO_LOGIN=false
# Show the scheduled report debug notification test action. Default is disabled.
VITE_SCHEDULED_REPORT_DEBUG=false
# AI API Configuration (Optional, can be entered in UI)
# 注意:发布版本不再自动读取以下环境变量。
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
+3 -10
View File
@@ -25,16 +25,10 @@ jobs:
node-version: 22
cache: pnpm
- uses: actions/setup-go@v5
with:
go-version-file: services/wechat-connector/go.mod
cache-dependency-path: services/wechat-connector/go.sum
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install
- name: Install Playwright Chromium
if: runner.os == 'macOS'
run: pnpm exec playwright install chromium
- name: Type check
@@ -52,9 +46,6 @@ jobs:
- name: Skill installation instruction tests
run: pnpm test:skill-install
- name: WeChat connector tests
run: pnpm test:wechat-connector
- name: Build Electron test application
run: pnpm test:e2e:build
@@ -64,6 +55,8 @@ jobs:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Platform visual regression
# GitHub-hosted Windows 无法稳定容纳项目统一的 1400×800 Electron 窗口,Windows visual regression 改由可控 Windows 桌面环境执行。
if: matrix.os != 'windows-latest'
run: pnpm exec playwright test tests/e2e/visual.spec.ts
env:
WXE_E2E_CLOSE_DELAY_MS: 0
+9 -9
View File
@@ -1,5 +1,6 @@
node_modules
*.tsbuildinfo
electron.vite.config.[0-9]*.mjs
dist
out
.env
@@ -9,19 +10,18 @@ out
coverage/
playwright-report/
test-results/
resources/connectors/wechat/
resources/connectors/wechat-personal/
.omc
.codex/
services/share-card-worker/.wrangler/
services/share-card-worker/wrangler.local.jsonc
skills-lock.json
docs/design/
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md
AGENTS.md
*__screenshots__
/AGENTS.md
/CLAUDE.md
/.claude/
/.workbuddy/
/.ai-local/
findings.md
progress.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" />
</p>
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
<h2 align="center">把微信里的信息,记住、理解、监控,并在需要时行动</h2>
<p align="center">
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
查看聊天 · 找回信息 · AI 问答 · 微信群聊总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
</p>
<p align="center">本地优先的微信数据、AI 分析与自动化工作台</p>
<p align="center">
<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/README.md"><b>完整文档</b></a>
·
<a href="./docs/concepts/how-it-works.md"><b>TraceMemo 如何工作</b></a>
</p>
<p align="center">
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
<img src="./public/日报.png" alt="TraceMemo 主界面" />
</p>
<p align="center">
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" />
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
</p>
<p align="center">
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
</p>
---
## TraceMemo(迹忆)是什么
## 🎨 社区日报模板
TraceMemo(迹忆)是一款**本地优先、可追溯的 AI 微信知识与分析工作台**。
TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多样式。社区模板与默认日报读取同一份真实日报数据,只改变展示方式,适合手机长图分享、桌面归档、团队复盘等不同场景。
TraceMemo 原名 **WechatExplorer**,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
<p align="center">
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates"><b>浏览 TraceMemo 模板社区</b></a>
·
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor"><b>用 AI 制作并投稿模板</b></a>
</p>
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
在 TraceMemo 中打开:
你可以直接浏览聊天,也可以用自然语言提问:
**日报 → 今日日报 → 日报模板 → 模板市场**
> “上个月我们讨论过哪些发布问题?”
>
> “张三之前发过的项目地址在哪里?”
>
> “技术交流群今天有哪些结论和待办?”
即可查看、预览、安装和切换已发布的社区模板。
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。
如果你有一张喜欢的日报长图、网页或前端项目,也可以把它交给 Codex、ChatGPT 或其他能够读取 GitHub 仓库的 AI,并让它读取 [TraceMemo Template Contributor Skill](https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor)。AI 可以帮助你完成模板转换、真实预览,并在你确认满意后向 [TraceMemo-Templates](https://github.com/Wxw-Gu/TraceMemo-Templates) 提交 Pull Request。
你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
TraceMemo 不提供任何微信聊天数据,也不鼓励收集、上传、出售、共享或未经授权处理他人的聊天记录。使用 TraceMemo 时,请确保你对所处理的数据具有合法的访问和使用权限,并自行承担相应的数据安全与合规责任。
模板通过审核并正式发布后,其他 TraceMemo 用户即可在模板市场中安装使用。
---
## 为什么叫 TraceMemo(迹忆)
## TraceMemo 是什么
<details>
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数据、AI 分析与自动化工作台,把聊天变成可浏览、可搜索、可理解、可追溯的信息。
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
先用档案找原话,再按需要使用 AI Search、日报、监控或 Agent。普通浏览、搜索和导出不需要 AI。
“迹忆”可以理解为“留下痕迹的记忆”。
## 核心能力
TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
> **品牌说明**
>
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
## 💻 平台支持
</details>
TraceMemo 2.4.0 支持:
---
- **Windows x64**
- **macOS Apple Silicon(M 系列 / arm64)**
- **macOS Intel(x64)**
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
## 项目缘起
@@ -88,11 +96,11 @@ TraceMemo 最早叫 **WechatExplorer**。
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
为了支持微信 4.x,我开始重新研究数据访问。这部分工作得到了 **WeFlow** 很大的帮助。TraceMemo 目前的微信 4.x 数据连接能力,参考并使用了 **WeFlow 历史版本中的相关实现和思路**,包括数据库密钥获取、图片解密等底层能力。
为了支持微信 4.x,我开始重新研究数据访问。这部分工作最初得到了 **WeFlow** 很大的帮助。早期 TraceMemo 曾参考 WeFlow 历史版本中的实现和思路,借此解决了数据库消息、密钥获取等微信 4.x 数据访问问题。
> **没有 WeFlow,就没有今天的 TraceMemo。**
随着项目继续发展,我逐步把这部分底层能力从原有实现中抽离,并重新实现了一套独立的数据访问兼容层。目前会继续保持与 WeFlow 历史接口和行为的兼容,以减少上层业务迁移成本。
WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继续做后面的事情:让聊天记录可以被搜索、理解和总结,也让 AI 给出的答案能够回到原始消息核对。
也就是说,**WeFlow 是 TraceMemo 进入微信 数据访问领域的重要起点。没有 WeFlow,就没有今天的 TraceMemo。**
在此基础上,项目陆续加入了:
@@ -105,12 +113,15 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
- Reader Skill
- Agent 接入
- 多种聊天记录导出能力
- 退群监控
- 文字转语音
- 持续监控自动化能力
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。
感谢每一位使用、关注和反馈过的人。
</details>
@@ -124,298 +135,54 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
## 从你的任务开始
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
| 把群聊日报生成微信分享卡片(实验性) | [微信分享卡片](./docs/deployment/experimental-wechat-share-card.md) | 自备 Cloudflare、域名和微信测试号 |
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
---
## 最核心的三个能力
### 生成群聊日报
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。
报告包含:
- 热点
- 重要消息
- 资源
- 问答
- 待办
- 未解决事项
- 活跃统计
- 图片精选
具体内容取决于消息、媒体是否可读以及模型能力。
详细说明:[生成群聊日报](./docs/user-guide/report.md)
</details>
### AI 帮你找回聊过的内容
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。
你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
<p align="center">
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
</p>
详细说明:[使用 AI 查找聊天信息](./docs/user-guide/ai-search.md)
### 直接在微信里问你的历史聊天
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。
例如,你可以直接给机器人发送:
- “最近 5 个会话”
- “张三最近和我聊了什么”
- “总结今天的技术交流群”
TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。
当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
---
## 其他能力
### 本地知识库
<details>
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。
它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
</details>
### 实验性:生成微信分享卡片
<details>
TraceMemo 可以把群聊日报长图上传到你自己部署的 Cloudflare Worker 和 R2,并生成可在微信中分享的临时网页、二维码及卡片信息。
该功能需要自备 Cloudflare 账号、域名和微信测试号,目前不属于开箱即用的稳定功能。
<p align="center">
<img src="./public/微信卡片分享.png" alt="微信卡片分享效果示例" />
</p>
详细说明:[实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)。
不熟悉命令行的用户,可以把[自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)直接交给 Codex 或 Claude Code。
</details>
### 转写微信语音
<details>
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。
转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
详细说明:[语音转文字](./docs/user-guide/voice.md)
</details>
### 导出长期可用的聊天档案
<details>
支持 HTML、CSV、JSON 和 Markdown。
HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
详细说明:[导出聊天](./docs/user-guide/export.md)
</details>
### 在外部 Agent 中查询微信历史
<details>
通过 Reader Skill 和本机 Local HTTP API,Codex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。
这和微信机器人是两条不同路径:
- **微信机器人**:收到消息后在微信中回复。
- **外部 Agent**:主动查询历史。
安装和技术说明请看[Agent 接入概览](./docs/agent/overview.md)与[Local HTTP API](./docs/agent/api.md)。
</details>
---
## 它如何工作
```mermaid
flowchart LR
A[本机微信数据] --> B[TraceMemo 读取与解析]
B --> C[聊天档案]
B --> D[本地知识库与搜索]
D --> E[筛选相关聊天来源]
E --> F[用户配置的 AI 模型]
F --> G[带来源的回答]
B --> H[整理日报输入]
H --> F
B --> I[聊天导出]
B --> J[Local HTTP API]
J --> K[外部 Agent]
L[微信机器人消息] --> M[Agent Hub]
M --> B
M --> F
```
- 微信数据库读取、聊天解析、知识库索引和离线语音识别在本机完成。
- 普通浏览、普通搜索和导出不要求配置 AI 服务。
- 使用“问问微信”、群聊日报或图片理解等 AI 功能时,完成任务所需的内容可能发送到你选择的模型服务;具体发送范围和确认方式以对应功能页面为准。
- “问问微信”会先在本机缩小范围,不会默认把整个微信数据库作为一次模型请求发送。
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
---
## 支持平台与安装包
| 平台 | 处理器架构 | Releases 安装包 |
| ------- | ------------------------------ | --------------- |
| Windows | x64 | `-setup.exe` |
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
当前版本不支持 Intel 芯片的 Mac。
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
---
| 想做什么 | 使用入口 |
| ---------------------------------- | ----------------------------- |
| 找记得原文或关键词的消息 | 档案搜索 |
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
| 长期跨群查询历史 | 本地知识库 |
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
| 持续关注群成员退出 | 退群监控 |
| 按计划生成并发送群聊日报 | 定时日报 |
| 把文字生成微信语音 | 文字转语音 |
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
| 把聊天保存成文件 | 导出 |
## 快速开始
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载安装包。
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载对应平台的安装包。
2. 启动应用,按“第一次使用”页面选择微信数据目录并完成连接。
3. 打开“档案”,确认联系人和消息已加载后开始搜索。
4. 需要 AI 时,在“设置 → AI 模型”添加并测试 Provider。
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。
迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
---
## 配置 AI
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。
应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
---
详细步骤见[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)。
## 文档
- [文档首页](./docs/README.md)
- [第一次使用](./docs/user-guide/getting-started.md)
- [聊天档案与搜索](./docs/user-guide/chat-archive.md)
- [AI 查找聊天信息](./docs/user-guide/ai-search.md)
- [本地知识库](./docs/user-guide/knowledge.md)
- [群聊日报](./docs/user-guide/report.md)
- [实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)
- [微信分享卡片自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)
- [语音转文字](./docs/user-guide/voice.md)
- [导出聊天](./docs/user-guide/export.md)
- [防撤回](./docs/user-guide/recall-protection.md)
- [数据、隐私与安全](./docs/user-guide/privacy.md)
- [Agent 接入](./docs/agent/overview.md)
- [微信机器人与 Agent Hub](./docs/agent/agent-hub.md)
- [Local HTTP API](./docs/agent/api.md)
- [开发与测试](./docs/development/overview.md)
- [用户指南](./docs/README.md#用户指南)
- [AI / Knowledge](./docs/README.md#ai-与知识库)
- [Monitor / Automation](./docs/README.md#日报与自动化)
- [Agent / API](./docs/README.md#agent--api)
- [开发文档](./docs/development/overview.md)
- [隐私与安全](./docs/user-guide/privacy.md)
---
完整目录由[文档首页](./docs/README.md)维护。
## 本地开发
## 支持平台
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
| 平台 | 架构 | 微信连接 | 安装包 |
| ------- | ------------------------------ | ------------------------------------- | ------------------------------- |
| Windows | x64 | 支持微信 4.x | `tracememo-<version>-setup.exe` |
| macOS | Apple Silicon(M 系列、arm64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-arm64.dmg` |
| macOS | Intel(x64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-x64.dmg` |
```bash
pnpm install
pnpm dev
```
## 参与贡献
常用检查:
稳定版在 `main`,只在发版时更新;所有改动都先进 `develop`,随**下一个版本**一起发布。
```bash
pnpm typecheck
pnpm test:unit
pnpm test:component
pnpm test:integration
pnpm test:e2e:build
```
**提 PR 请基于 `develop` 拉新分支,并把 PR 的目标分支设为 `develop`** —— 指向 `main` 的 PR 会被直接关闭。
完整说明:[开发、测试与构建](./docs/development/overview.md)
---
## 支持与反馈
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
提交 Issue 时请提供:
- 操作系统
- 微信版本
- TraceMemo 版本
- 复现步骤
- 已遮挡敏感信息的截图
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
---
## 许可说明
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件。
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
---
分支流程、提交信息风格、PR 前自检,以及**给 AI Agent 的硬性规则**,都在[参与贡献指南](./CONTRIBUTING.md)。
## 致谢
@@ -423,18 +190,21 @@ TraceMemo 的诞生离不开开源社区中许多优秀项目的工作。
### 特别感谢 WeFlow
TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库密钥获取、图片解密等底层能力。
TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库访问、密钥获取等底层能力。
特别感谢作者 **hicccc77** 的理解和包容。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
特别感谢作者 **[hicccc77](https://github.com/hicccc77)**。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
### 其他参考项目
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
- 提供了数据库解析相关思路。
- 提供了数据解析相关思路。
- **[chatlog](https://github.com/sjzar/chatlog)**
- 提供了数据处理方面的参考。
- **[wechat_chatter](https://github.com/yincongcyincong/wechat_chatter)**
- 提供了发送方面的参考。
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
---
+49 -42
View File
@@ -1,64 +1,71 @@
# TraceMemo 文档
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
文档按产品任务和使用场景组织。需要集成或开发时,再看 Agent、概念和开发文档。
## 从这里开始
## 档案与搜索
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
## 你可以完成的任务
## AI 与知识库
- [建立本地知识库](./user-guide/knowledge.md)
- [生成群聊日报和总结](./user-guide/report.md)
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
- [语音转文字](./user-guide/voice.md)
- [导出聊天档案](./user-guide/export.md)
- [防撤回](./user-guide/recall-protection.md)
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
- [数据、隐私与安全](./user-guide/privacy.md)
- [常见问题与排查](./user-guide/troubleshooting.md)
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
## 如果你想了解 AI 为什么这样回答
## 日报与自动化
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
## 微信机器人和外部 Agent
## Monitor
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
| 你想做什么 | 应该看哪里 |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| 在微信里给机器人发消息,让本机读取数据、生成总结并回复 | [Agent Hub](./agent/agent-hub.md) |
| 在 Codex、Claude Code、OpenClaw 等外部 Agent 中主动查询过去的微信数据 | [Reader Skill](./agent/reader-skill.md) + [Local HTTP API](./agent/api.md) |
## 语音能力
### 在微信里提问
- [语音转文字](./user-guide/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
打开应用一级导航中的“Agent”,进入“Agent Hub”后扫码登录微信机器人。机器人收到文字消息后,可以查询最近会话、读取联系人聊天、生成群聊总结图片或总结群成员发言,并把结果回复给发消息的人。它需要本地微信数据库已经连接;依赖 AI 的任务还需要配置 AI 服务。
## Agent / API
- [Agent Hub](./agent/agent-hub.md):连接机器人、查看运行状态和了解实时交互边界。
Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP API 让外部 Agent 主动查询历史数据。
### 让外部 Agent 查询历史微信
- [Agent 接入概览](./agent/overview.md)
- [Agent Hub](./agent/agent-hub.md)
- [Reader Skill](./agent/reader-skill.md)
- [Local HTTP API](./agent/api.md)
- [API 安全](./agent/api-security.md)
连接 Reader Skill 后,你可以询问:
## 导出与隐私
> “总结今天技术交流群讨论了什么。”
> “过去一周有没有人提到这个项目?”
- [导出聊天](./user-guide/export.md):导出 HTML、Markdown、CSV 或 JSON 档案。
- [数据、隐私与安全](./user-guide/privacy.md):本地处理、Provider、媒体和 Token 的数据边界。
- [常见问题与排查](./user-guide/troubleshooting.md):按安装、连接、AI、媒体和 Agent 现象排查。
- [Agent 接入概览](./agent/overview.md):先选择适合你的接入方式。
- [Reader Skill](./agent/reader-skill.md):安装并让外部 Agent 按需读取聊天。
- [Local HTTP API](./agent/api.md):完整端点和请求示例。
- [API 安全](./agent/api-security.md):Bearer Token、CORS、轮换和边界。
## 开发文档
## 开发与平台
- [macOS 数据访问说明](./platform/macos.md)
- [开发、测试与构建](./development/overview.md)
- [界面开发规范:按钮与主题色](./development/ui-guidelines.md)
- [微信系统消息解析与格式兼容](./development/wechat-system-message-parsing.md)
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.md)
- [本地启动排障](./development/local-startup-troubleshooting.md)
- [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md)
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
- [macOS 数据访问说明](./platform/macos.md)
- [关闭 SIP 教程](./mac-disable-sip.md)
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
## 实验性功能与第三方
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
## 版本说明
- [v2.2.0 品牌与安全迁移](./agent/release-notes-v2.2.0.md)
- [v2.1.9 API 鉴权迁移](./agent/release-notes-v2.1.9.md)
文档按当前 develop 已实现的能力维护,不在首页固定写死版本号。版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
+88 -4
View File
@@ -36,7 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
| GET | `/api/v1/media/{messageId}` | 获取图片消息的二进制资源 | 使用 `/chatlog` 返回的图片消息 `id` |
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
@@ -85,9 +85,9 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
- `200`:请求成功;
- `401`:缺少、错误或已失效的 Bearer Token;
- `400`:参数或 JSON 请求体无效;
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片;
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
- `403`:浏览器 Origin 不在允许的 loopback 列表;
- `404`:端点、会话或群聊不存在;
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
- `503`:数据库或 Agent Hub 尚未就绪;
- `500`:服务端处理或报告渲染失败。
@@ -102,13 +102,97 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
"media": {
"type": "image",
"available": true,
"url": "/api/v1/media/msg_xxx"
"url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
}
```
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。
## 与 MCP 的关系
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
## LLM-friendly Query Tool API
这些端点提供稳定的结构化 Query primitive,不接收自然语言问题,也不会调用 AI。它们与现有 API 共用端口、Bearer Token、loopback 和 CORS 安全策略。
```bash
BASE="http://127.0.0.1:6131/api/v1"
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
# 能力目录
curl -H "$AUTH" "$BASE/query/capabilities"
# BOBO 的第一条真实互动
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"direction":"any","order":"asc","limit":1,"excludeSystem":true}'
# 上个月 BOBO 发来的文件
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"},"direction":"from_target","messageTypes":["file"],"order":"desc","limit":1}'
# 受限语义关键词检索(最多 4 个 variants)
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/search" \
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"query":"答应之后给我或者帮我完成某件事情","variants":["我给你","我发你","弄好给你"],"limit":20}'
# 按会话和时间范围提取可供总结的证据
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/conversation-overview" \
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"}}'
```
`query/messages` 的 `messageRef` 是服务端生成的不透明引用,可直接传给 `query/message-context` 获取前后文;不要自行构造 wxid、md5 或数据库路径。
每条消息都会返回 `messageType`(`text`、`image`、`voice`、`video`、`file`、`link`、`sticker`、`system` 或 `other`)。非文本消息不会伪造 `text`;可识别的图片、视频、贴纸和文件会返回不含密钥或本地路径的 `attachment` 元数据。
`conversation-overview` 同时返回 `sourceCoverage` 与 `selection`:前者描述时间范围内源消息是否完整及 `sourceMessageCount`,后者描述从源消息中选出的 Evidence 数量及是否抽样。`evidence` 最终按 `timestamp` 升序返回,`messageRef` 是唯一推荐的消息引用。
`conversation-overview` 另有一个 `origin` 字段:`wcdb` 表示这次证据直接来自本机聊天数据库(会话概览的事实来源),`knowledge` 表示来自本地索引。
### 搜索范围(scope)
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
| scope | 含义 |
| ----- | ---- |
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
`conversationId` 是会话标识,可用 `/api/v1/resolve` 或 `/api/v1/contact` 得到。`scope` 一旦给出就是**权威边界**:`target` 落在范围之外会被拒绝(`status: "invalid_tool_arguments"`、`constraint: "target_outside_scope"`),不会静默扩大范围;范围里包含多个会话时,`query/messages` 与 `query/conversation-overview` 必须显式指定 `target`(`constraint: "target_required_for_scope"`)。
响应会回显实际生效的边界:
```json
{ "scope": { "kind": "groups", "conversationCount": 243 } }
```
跨会话检索时,`evidence` 的每一项都会带上它所属的会话,便于把结果归属到具体群 / 联系人与具体成员:
```json
{
"messageRef": "…",
"conversationName": "某个群",
"conversationType": "group",
"sender": "某成员",
"timestamp": 1789099069000,
"text": "…"
}
```
### 索引新鲜度(freshness)
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
| 字段 | 含义 |
| ---- | ---- |
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `pending` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。
`query/messages` 与 `query/conversation-overview` 直读聊天数据库,不受索引新鲜度影响。
+2 -2
View File
@@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
- Knowledge 正在同步时,新的分析会被暂停;
- Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围;
- 语音没有转写时,AI 可能只能看到消息类型;
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
- 你只选择了一个群,答案不会自动代表所有聊天。
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
## 这不是事实保证
+37
View File
@@ -19,8 +19,45 @@ flowchart LR
M[微信机器人消息] --> N[Agent Hub]
N --> B
N --> F
B --> O[Monitor / Snapshot]
O --> P[Proposed Action]
F --> P
P --> Q[Policy]
Q --> R[Action Gateway]
R --> S[Personal WeChat Send Capability]
S --> T[Action Audit / Logs]
```
## Remember → Understand → Monitor → Act
TraceMemo 的工作方式可以概括为:
```text
Remember → Understand → Monitor → Act
```
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
## 退群监控
退群监控使用成员快照判断变化:
```text
Current Membership → Snapshot Diff → Member Event
```
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
## 动作执行与审计
自动发送和监控动作经过统一边界:
```text
Feature → Policy → Gateway → Capability → Execution → Audit
```
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
## 哪些步骤在本机
- 微信数据库读取与解析;
@@ -6,7 +6,6 @@
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`;
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`;
- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`。
@@ -22,41 +21,28 @@ WCDB_DEBUG_LOGS=1 pnpm dev
开启后会输出 `GETMSG-xxx` 请求耗时和 native `WCDB-EXPLAIN` 执行计划,不记录聊天正文。取消该环境变量或设为 `0` 即可关闭。
## Go 命令找不到
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
```bash
go version
```
命令不可用表示当前终端的 `PATH` 没有找到 Go。Windows 默认安装位置是 `C:\Program Files\Go\bin`。确认 Go 已安装并把该目录加入系统 `PATH` 后,关闭并重新打开终端或 IDE,再重新执行 `go version` 和 `pnpm dev`。
如果 Go 刚完成安装,已经打开的终端不会自动继承新的环境变量;重开终端是必要步骤。不要绕过连接器构建直接启动 `electron-vite dev`,否则 Agent Hub 的微信连接器不会生成。
## Electron 二进制缺失或下载失败
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
项目的 [`.npmrc`](../../.npmrc) 已设置:
**先看根因,别急着删 `node_modules` 重装。** `electron@43` 的 npm 包**不再声明 `postinstall`**(其 `package.json` 里 `scripts` 是空对象),下载改为「首次 `require('electron')` 时的懒加载」。因此:
```ini
electron_mirror=https://npmmirror.com/mirrors/electron/
```
- `package.json` 里的 `pnpm.onlyBuiltDependencies: ["electron"]` 对它不起作用——上游没有脚本可执行,pnpm 无从下手;
- `pnpm install` 跑完不会有任何二进制被下载,**只重装依赖解决不了这个问题**。
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
项目已自动兜住这条路径:`scripts/ensure-electron-binary.cjs` 挂在 `postinstall` 与 `predev` 上,校验 `path.txt` 指向的可执行文件是否真的存在(只有 `path.txt` 而没有 `dist/` 同样算没装好),缺失时就地补下载。它读取 `.npmrc` 的 `electron_mirror`(当前为 `https://npmmirror.com/mirrors/electron/`),失败后再兜底重试一次该镜像。
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
正常情况下你不需要做任何事。只有当自动步骤没有执行时(例如安装时带了 `--ignore-scripts`),才需要手动补一次:
```bash
pnpm install --frozen-lockfile
node scripts/ensure-electron-binary.cjs
```
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
要换用别的镜像时,显式设置环境变量(优先于 `.npmrc`)。PowerShell 示例:
```powershell
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
pnpm install --frozen-lockfile
node scripts/ensure-electron-binary.cjs
```
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
@@ -69,4 +55,4 @@ Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时
## 仍无法启动时
保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
保留首次错误的完整输出,并同时记录操作系统、Node.js 与 pnpm 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
+14 -15
View File
@@ -6,7 +6,6 @@
- Electron + React + TypeScript;
- pnpm 7+;
- Go(构建微信连接器);
- 平台对应的 Electron/native 构建环境。
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
@@ -18,7 +17,7 @@ pnpm install
pnpm dev
```
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
本地依赖安装与 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
常用检查:
@@ -30,21 +29,21 @@ pnpm test:integration
pnpm test:e2e:build
```
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、构建和 Playwright 测试;需要对应平台环境。
## 代码变更对应文档
| 代码区域 | 需要同步检查的文档 |
| --------------------------------------------------------- | ---------------------------------------------------------- |
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
| `src/main/services/recall-archive-service.ts`、防撤回设置 | `user-guide/recall-protection.md`、`user-guide/privacy.md` |
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
| 代码区域 | 需要同步检查的文档 |
| --------------------------------------------------------- | ------------------------------------------------------- |
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
| `src/main/services/recall-archive-service.ts` | `user-guide/privacy.md` |
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
## 文档检查
@@ -52,7 +51,7 @@ pnpm test:e2e:build
```bash
git diff --check
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!DOCUMENTATION_AUDIT.md' --glob '!development/overview.md'
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
```
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
+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 会直接返回错误,不会回退到另一套模型配置。
+171
View File
@@ -0,0 +1,171 @@
# 界面开发规范:按钮与主题色
这份规范回答一件事:**为什么同一个产品里,有的按钮是主题色,有的还是浏览器默认的黑白方角。**
先看一个真实案例 —— 同一屏里的两组按钮:
```
主界面:「更新图片文字索引」 ← 主题色(正确)
弹窗里:「取消」「开始索引」 ← 浏览器默认样式(错误)
```
两者渲染出来完全不同,用户会以为是两个不同的产品。根因不是"设计没定颜色",
而是**组件在导出时把样式丢了**。下面写清楚怎么避免。
---
## 1. 永远不要写裸 `<button>`
任何可点的按钮都必须来自 `components/ui/button`:
```tsx
import { Button } from '../ui'
<Button variant="outline" onClick={handleCancel}>取消</Button>
```
**唯一的例外**:结构性控件(导航项、Tab、列表行、图标热区)——它们有自己
成套的布局样式,用原生 `<button>` 是合理的,但**必须**带 `className`,
且样式写在对应的 `.scss` 里,不要在 JSX 里临时拼颜色。
```tsx
// 可以:结构性控件,样式来自 .scss
<button type="button" role="tab" className={active ? 'active' : ''} onClick={...}>
今日日报
</button>
```
**判据**:如果这个按钮在别的界面也会以同样形态出现("取消"、"保存"、"删除"),
它就该是 `Button`;如果它只在某一个位置有意义(侧栏导航项),才考虑原生。
---
## 2. 三种角色,只有三个默认变体
`Button` 提供 6 个变体,但**日常只用其中 3 个**:
| 角色 | `variant` | 长什么样 | 用在哪 |
| --- | --- | --- | --- |
| 主要 | `default` | 主题色实底 | 这一步用户唯一该做的事 |
| 次要 | `outline` / `ghost` | 描边 / 无底色 | 取消、返回、并列的辅助操作 |
| 危险 | `destructive` | 红色实底 | 删除、清空、不可恢复的操作 |
另外两个(`secondary` / `link`)按需用;`link` 只用于正文里的行内跳转。
**一条硬约束:同一个界面(或同一个弹窗)里,`default` 最多出现一次。**
两个主题色实底按钮并排,等于没有主次。
---
## 3. 弹窗按钮:组件已经带样式了,不要再包一层
`AlertDialogCancel` 和 `AlertDialogAction` **自带**按钮样式(分别是 `outline`
和 `default`),直接写文字即可:
```tsx
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleStart}>开始索引</AlertDialogAction>
</AlertDialogFooter>
```
**不要**再套一层 `Button`:
```tsx
// 反面写法:外层已经有样式了,再包一层只会产生重复类名
<AlertDialogCancel asChild>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
```
需要危险动作时,用 `className` 覆盖(`cn` 走 tailwind-merge,同族类后者生效):
```tsx
<AlertDialogAction className="bg-destructive text-destructive-foreground">
删除
</AlertDialogAction>
```
---
## 4. 颜色只能用语义 token,禁止硬编码
颜色全部走 Tailwind 的语义类,它们背后是 `--tm-*` 变量,换主题时自动跟随:
```
背景 bg-primary / bg-surface / bg-accent / bg-destructive
文字 text-foreground / text-primary-foreground / text-muted-foreground
描边 border-border / border-border-subtle / border-disabled-border
```
```tsx
// 对
<Button className="bg-primary text-primary-foreground">保存</Button>
// 错 —— 换主题时这行不会跟着变
<Button className="bg-[#247a63] text-white">保存</Button>
```
**判据**:JSX 里出现 `#` 开头的颜色、`rgb(...)`、或 Tailwind 的调色板名
(`bg-green-600`、`text-slate-500`)—— 都是漏用 semantic token 的信号。
---
## 5. 「默认样式」的三个常见来源
排查界面里冒出来的黑白方角按钮时,按这个顺序找:
**① 组件导出时把样式丢了。** 最常见。把 Radix 的 primitive 原样导出:
```tsx
// 错:渲染出来就是浏览器默认按钮
const AlertDialogCancel = AlertDialogPrimitive.Cancel
```
正确做法是 `forwardRef` 包一层,挂上 `buttonVariants`:
```tsx
const AlertDialogCancel = React.forwardRef<...>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
))
```
**判据**:`components/ui/` 里凡是导出 Radix primitive 的地方,都要确认它是
"样式化的封装"还是"原样透传"。原样透传只对布局容器(`Root` / `Portal` /
`Group`)成立,对**可点元素**(`Close` / `Action` / `Cancel` / `Item`)不成立。
**② `asChild` 里重复包了一层。** 外层已经带样式、子元素又带一次,虽然因为
同族类后生效而不会出错,但会产生冗余类名。**能去掉一层就去掉。**
**③ 原生 `<button>` 忘写 `className`。** 见第 1 节的例外条款 —— 结构性控件也必须
有样式来源。
---
## 6. 提交前检查清单
- [ ] 新增的可点元素来自 `Button`,不是裸 `<button>`
- [ ] 同一界面里 `default` 变体不超过一个
- [ ] 危险操作走 `destructive`,不是红色硬编码
- [ ] 弹窗按钮没有重复包 `Button`
- [ ] JSX 里没有 `#` 开头的颜色、没有 Tailwind 调色板名
- [ ] `components/ui/` 里新导出的可点 primitive 已经挂上 `buttonVariants`
- [ ] 组件测试覆盖到按钮的可见性与点击行为(testid 用 `xxx-yyy` 连字符命名)
---
## 7. 一个反面案例的复盘
弹窗里的「取消 / 开始索引」显示成浏览器默认样式,原因就是第 5 节第 ① 条:
`alert-dialog.tsx` 把 `Cancel` / `Action` 两个 primitive 原样导出了。
修复是给它们各加一个 `forwardRef` 封装,挂上 `buttonVariants`。**组件本身没坏**,
所有调用方一行不用改,样式自动生效 —— 这正是把样式收在 `components/ui/` 里的价值:
**修一处,全产品对齐。**
如果你发现某个地方的按钮"没跟上主题",先别去改那个界面 ——
**先看它用的组件是不是漏了样式。**
@@ -0,0 +1,92 @@
# 微信系统消息(sysmsg)解析与格式兼容
微信的「系统消息」(入群、撤回、成员变动等)以 XML(`<sysmsg>`)存放在消息内容里,
但**同一类提示的 XML 结构会随客户端版本变化**。本文说明 TraceMemo 的解析方式,
以及在遇到新格式时应当怎么扩展。
## 两类格式
### 旧格式:正文直接放在 `<plain>`
```xml
<sysmsg type="delchatroommember">
<delchatroommember>
<plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
<text><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></text>
<link>
<scene>qrcode</scene>
<text><![CDATA[撤销]]></text>
</link>
</delchatroommember>
</sysmsg>
```
解析:命中 `delchatroommember`,直接取 `<plain>`。
### 新格式:正文在 `<template>`,用 `$名称$` 引用 link
```xml
<sysmsg type="sysmsgtemplate">
<sysmsgtemplate>
<content_template type="tmpl_type_profilewithrevokeqrcode">
<plain><![CDATA[]]></plain>
<template><![CDATA["$adder$"通过扫描你分享的二维码加入群聊 $revoke$]]></template>
<link_list>
<link name="adder" type="link_profile">
<memberlist><member>
<username><![CDATA[wxid_xxxxxxxx]]></username>
<nickname><![CDATA[成员昵称]]></nickname>
</member></memberlist>
</link>
<link name="revoke" type="link_revoke_qrcode" hidden="1">
<title><![CDATA[撤销]]></title>
</link>
</link_list>
</content_template>
</sysmsgtemplate>
</sysmsg>
```
三个要点:
- `<plain>` 变成**空 CDATA**,正文挪进 `<template>`;
- 正文里的 `$名称$` 是占位符,按 `<link_list>` 中 `link[name]` 回填;
- `hidden="1"` 的 link 在微信里是**可点击按钮**,纯文本展示时应省略其文案。
## 解析流程
`src/main/message-parser.ts` 的 `parseSystemMessage()` 按以下顺序尝试:
| 顺序 | 分支 | 处理对象 |
| --- | --- | --- |
| 1 | `extractRecallMessage` | `<revokemsg>` 撤回通知 |
| 2 | `extractSysmsgTemplateText` | `<sysmsgtemplate>` 模板消息 |
| 3 | `extractDelChatroomMemberText` | `<delchatroommember>` 成员变动 |
| 4 | 通用提取(`plain` → `text` → `title`),再退回 `fallbackSystemText` | 其余未覆盖类型 |
第 4 步之前会先调用 `stripSysmsgLinkList()` 剥掉 `<link_list>`。
## 为什么必须显式处理新格式
通用提取链只在第 1~3 步全部落空时才执行,而新格式恰好让它落空:
`<plain>` 是空 CDATA,又没有 `<text>`,于是取到 `<title>` ——
那是 `hidden="1"` 按钮的标题。**结果是整条系统消息只剩一个按钮文案**,
例如把「某某通过扫描你分享的二维码加入群聊」显示成「撤销」。
因此三处约束缺一不可:
1. 模板分支必须排在通用提取之前;
2. 占位符回填必须尊重 `hidden="1"`;
3. 通用提取前先剥 `<link_list>`,作为未知类型的防护。
## 新增一类系统消息时
1. 从真实消息中取出 `content`(`<sysmsg>` 原文),确认 `type` 与承载正文的标签;
2. 在 `parseSystemMessage()` 里加一个**早于通用提取**的分支;
3. 补 `tests/unit/message-parser.test.ts` 用例,**新旧两版各一条**,防止回归;
4. 文档与代码注释只写结构,不粘贴真实会话内容、昵称、wxid 或二维码链接。
## 相关位置
- 解析实现:`src/main/message-parser.ts`
- 单元测试:`tests/unit/message-parser.test.ts`
+60 -3
View File
@@ -1,5 +1,62 @@
# macOS 数据访问说明(兼容入口)
# macOS 关闭 SIP 教程
完整内容已移到[macOS 数据访问与系统权限](./platform/macos.md)。
SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
保留此文件是为了兼容应用内已经发布的帮助链接。请不要把“关闭 SIP”当作默认安装步骤;只有当当前连接页面明确要求时才处理,并在完成后恢复系统安全设置。
> 只在连接页面明确提示需要关闭 SIP 时才处理。首次连接失败时,先确认微信版本、账号目录和登录时机,再按本文操作。关闭 SIP 不是 TraceMemo 的常规安装步骤,也不应长期保持关闭。
## 准备
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
- 需要进入 macOS 恢复模式。
- 请先保存正在编辑的文件,并预留一次重启时间。
## 关闭 SIP
### Intel Mac
1. 关机。
2. 按下开机键后,立刻按住 `Command + R`。
3. 保持按住,直到进入 macOS 恢复模式。
### Apple Silicon Mac(M1/M2/M3/M4/M5)
1. 关机。
2. 长按开机键不放。
3. 直到出现启动选项界面后松开。
4. 选择"选项",进入 macOS 恢复模式。
### 在恢复模式中执行命令
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**。
2. 选择 **Terminal(终端)**。
3. 在终端中输入:
```bash
csrutil disable
```
4. 按回车执行。
5. 看到关闭成功提示后,重启电脑。
## 确认是否生效
重启回到正常桌面后,打开"终端",执行:
```bash
csrutil status
```
看到 `System Integrity Protection status: disabled.` 才算关闭成功。
若仍显示 `enabled`,说明没有生效。常见原因是没在恢复模式里执行,或系统刚做过大版本更新——
macOS 大版本更新会把 SIP 重置回开启状态,此前关过也会失效,需要重新按上面的步骤操作。
## 重新开启 SIP
拿到数据库密钥后,建议重新进入恢复模式,在终端中执行:
```bash
csrutil enable
```
然后重启电脑,恢复系统安全设置。
+13 -2
View File
@@ -8,7 +8,7 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
2. 确认微信数据目录指向当前账号。
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
3. 只在页面明确要求时处理系统授权或 SIP;关闭 SIP 的具体步骤见[关闭 SIP 教程](../mac-disable-sip.md),按页面提示完成密钥获取后,恢复你平时使用的安全设置。
4. 返回应用重新检测账号、数据库和图片资源状态。
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
@@ -23,5 +23,16 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
## Intel 与 Apple Silicon
从 Releases 选择与 Mac 处理器匹配的构建。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
TraceMemo 同时支持两种 Mac 架构:
- **Apple Silicon(M 系列、`arm64`)**
- **Intel Mac(`x64`)**
从 Releases 下载与你 Mac 处理器匹配的构建:
- Apple Silicon:`tracememo-<版本号>-arm64.dmg`
- Intel:`tracememo-<版本号>-x64.dmg`
两种架构都可以通过应用内的连接流程自动获取微信数据库密钥。首次连接时,TraceMemo 会根据当前机器架构进入对应的流程,按连接页面提示操作即可。
Apple Silicon 和 Intel 已适配微信 macOS `4.1.13`。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
+68 -19
View File
@@ -27,21 +27,70 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
## 端点速查
| 方法 | 路径 | 用途 |
| ---- | --------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
| POST | `/agent/send` | 已连接机器人发送测试 |
| 方法 | 路径 | 用途 |
| ------ | ----------------------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
| POST | `/scheduled-reports` | 创建定时日报任务 |
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
| POST | `/agent/send` | 已连接机器人发送测试 |
## 定时日报管理
定时日报由 TraceMemo 自己持久化和调度。Agent 只负责理解自然语言、解析群聊和时间,再调用上述 API;不要创建 cron、维护任务文件、计算下一次执行时间或自行发送微信。
### 创建任务
用户提出“每天早上 9 点给技术交流群发昨天的日报”时,按以下顺序执行:
1. 调用 `/health`,确认 TraceMemo 和数据库可用。
2. 调用 `/wechat-personal/send-capability`,只有 `capability.status === "ready"` 且 `capability.capabilities.image === true` 才允许继续。
3. 用户使用“今天”“昨天”等相对日期时调用 `/current_time`;日报任务的 `schedule.time` 使用 TraceMemo 本机时区的 `HH:mm`,不要转成 UTC。
4. 调用 `/chatroom` 或 `/contact?type=group` 查找群聊。名称匹配多个结果时,必须把候选项展示给用户并要求选择;不能猜测。
5. 使用唯一群聊的 `talker` 创建:
```json
{
"name": "技术交流群 · 每日日报",
"group": { "talker": "xxx@chatroom", "name": "技术交流群" },
"schedule": { "type": "daily", "time": "09:00" },
"reportRange": "yesterday",
"target": { "type": "wechat_group", "talker": "xxx@chatroom" },
"enabled": true
}
```
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
### 查看、修改和执行
- “我现在有哪些定时日报”调用 `GET /scheduled-reports`,使用返回的 `tasks` 展示任务名称、群聊、每天的时间、范围、目标和启停状态。
- 修改前先查询列表并确认唯一任务,再调用 `PATCH /scheduled-reports/:id`。只提交需要修改的字段,例如 `{"schedule":{"type":"daily","time":"10:00"}}`。
- 暂停调用 `/scheduled-reports/:id/disable`,恢复调用 `/scheduled-reports/:id/enable`。
- “现在执行一次”调用 `/scheduled-reports/:id/run`,不要改用 `/agent/group-report` 后自行发送微信;该接口和定时执行共用同一条链路。
- 查询执行结果调用 `/scheduled-reports/:id/executions`,根据 `status`、`startedAt`、`finishedAt`、`message` 和 `error` 向用户解释结果。
### 删除确认
删除是不可逆操作。收到删除请求后,先用任务列表找到唯一任务,向用户展示任务名称、时间、日报范围和发送目标并明确询问确认;只有用户明确确认后,才调用 `DELETE /scheduled-reports/:id`。
## 时间与上下文规则
@@ -58,7 +107,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
当 `/chatlog` 返回图片消息时:
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。
2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
5. 如果图片获取失败,明确说明无法读取图片。
@@ -71,7 +120,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
1. 调用 `/health`;必要时调用 `/current_time`。
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。
3. 请求该消息的 `media.url`,将返回的图片交给 Vision。
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
@@ -84,7 +133,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
- `404`:先用 `/resolve` 确认会话标识。
- `422`:`messageId` 无效,或消息不是可读取的图片。
- `404`:会话查询失败时先用 `/resolve` 确认会话标识;媒体请求表示标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在。
- `422`:媒体标识格式错误,或消息不是可读取的图片(`NOT_IMAGE`)。
- `503`:用户还没有完成数据库连接或对应服务未就绪。
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
+13
View File
@@ -41,6 +41,19 @@
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
## 查找过程和跳回原消息
查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。
查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。
点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息:
- 群聊来源打开的是那个群,而不是群里某个联系人;
- 会加载该消息前后的上下文,并滚动到它、短暂高亮;
- 只加载目标消息附近的一段,不会把整个会话历史全部读出来;
- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。
## 什么时候不要直接相信答案
- 来源很少,或时间范围与问题不一致;
+18 -6
View File
@@ -4,7 +4,7 @@
## 选择要看的会话
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
左侧会话列表可以浏览已读取到的联系人、群聊和公众号。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
如果你从 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. 开始前准备
| 系统 | 已测试的微信客户端 | 需要注意 |
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
TraceMemo 需要读取微信本地数据库。首次使用前,请确认已安装受支持的微信客户端,并按照连接页面完成数据库密钥获取。
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
| 系统 | 微信客户端 | 首次连接说明 |
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| macOS | **微信 4.1.13 系列**(推荐 [4.1.13.8](https://github.com/zsbai/wechat-versions/releases/tag/4.1.13.8))<br>备选 [4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 支持 Apple Silicon(M 系列)和 Intel Mac 自动获取数据库密钥。首次连接时,请按照 TraceMemo 页面提示完成系统授权及微信登录操作。 |
| Windows | **微信 4.x**(推荐 [4.1.9.57](https://github.com/iibob/wechat-win-archive/releases/tag/v4.1.9.57)) | 支持自动获取数据库密钥。首次使用时请确认微信数据目录正确,无需处理 macOS 的 SIP 设置。 |
### macOS 用户
TraceMemo 已支持两种 Mac 架构:
- **Apple Silicon(M1 / M2 / M3 / M4 等)**
- **Intel Mac(x64)**
两种架构均支持自动获取微信数据库密钥,TraceMemo 会根据当前 Mac 自动选择对应的连接方式。
Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取密钥时,请让微信停留在登录页面,并按照 TraceMemo 中显示的步骤操作。
> 不同 Mac 架构的连接流程可能略有区别,请始终以应用内「第一次使用」页面显示的提示为准。
### 关于微信版本
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
- 你需要有权访问要读取的微信账号和聊天数据。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
@@ -26,14 +43,16 @@
### Windows
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。
1. 从 Releases 下载 Windows x64 的 `tracememo-<版本号>-setup.exe` 安装包。
2. 双击安装包,按向导完成安装。
3. 启动 TraceMemo。
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
### macOS
1. 下载 Apple Silicon(M 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。
1. 从 Releases 下载与你 Mac 处理器架构匹配的 `.dmg`:
- Apple Silicon(M 系列、`arm64`):`tracememo-<版本号>-arm64.dmg`
- Intel(`x64`):`tracememo-<版本号>-x64.dmg`
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
4. 如果系统提示应用已损坏,可在终端执行:
@@ -42,7 +61,16 @@
xattr -cr "/Applications/TraceMemo.app"
```
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
5. 先让微信停在**未登录窗口**(如果微信已经登录,请退出当前账号,**只关闭窗口不算**),再启动 TraceMemo。
- 连接时会按提示自动获取数据库密钥;应用会根据当前架构进入对应流程,按连接页面显示的授权要求操作即可。
- **等第五步 明确提示可以登录后,再回到微信点击登录。** 在此之前不要先点登录,否则这次密钥获取会失败,需要重新开始。
- 只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
> **注意事项:取密钥时提示监听超时(`CAPTURE_TIMEOUT`,或相关失败)**
>
> 这说明「先让微信停在未登录窗口」这一步操作有误——常见原因是微信当时已经登录,或者还没等连接页面提示就先点了登录
>
> 请先退出微信账号、回到**未登录窗口**,然后按上面的第 5 步**重新来一遍**(等连接页面明确提示可以登录后,再回到微信点击登录)。
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
@@ -106,7 +134,6 @@
- [生成群聊日报或总结](./report.md)
- [转写微信语音](./voice.md)
- [导出聊天档案](./export.md)
- [可选开启防撤回](./recall-protection.md)
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
- [让外部 Agent 查询微信历史](../agent/overview.md)
@@ -142,7 +169,6 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
完整边界见[数据、隐私与安全](./privacy.md)。
+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 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
## 什么时候会请求外部服务
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
@@ -58,4 +56,3 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
- 对需要外发的 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 天的项目进展、待办和未解决问题;
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
@@ -16,7 +16,7 @@
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
2. 选择时间范围:今天、昨天或近 7 天。
2. 选择时间范围:今日、昨日或近 7 天。
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
4. 选择报告模板/内容模式并开始生成。
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
@@ -33,10 +33,22 @@
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
## 定时日报
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
```text
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
```
生成和发送是两个阶段。当前微信发送能力不可用、未绑定或发送失败时,报告仍会保存,PNG 和执行记录也会保留;这类结果会显示为“已生成,但未发送”或“已生成,发送失败”。
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
## 让报告更可靠
- 先选正确的群和时间范围;
- 不确定时先只选择文字消息;
- 群太活跃时分成“今天”和“近 7 天”两次生成;
- 群太活跃时分成“今日”和“近 7 天”两次生成;
- 看到待办和结论后回到原消息核对上下文;
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
+5 -5
View File
@@ -12,6 +12,7 @@
### macOS
- 确认下载的构建与 Mac 处理器匹配:Apple Silicon(M 系列)用 `arm64`,Intel 用 `x64`。
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
@@ -25,10 +26,13 @@
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
3. 微信是否处于页面要求的登录/退出状态;
4. macOS 是否完成页面要求的授权;
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题;
6. macOS 上 Apple Silicon 与 Intel 使用不同的连接流程,按连接页面提示操作;两者都可以自动获取数据库密钥。
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
仍然失败时,记录 **macOS 版本、CPU 架构(Apple Silicon / Intel)、微信版本、TraceMemo 版本和页面错误提示**后 扫码 README 文档二维码进群提交消息, 或者提交 Issue。
## 连接成功但没有联系人或消息
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
@@ -88,7 +92,3 @@ Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
5. 需要总结或自然语言理解时,AI Provider 是否可用。
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
## 防撤回没有保留消息
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
+5 -2
View File
@@ -19,6 +19,8 @@ asarUnpack:
- node_modules/silk-wasm/**
- node_modules/sherpa-onnx-node/**
- node_modules/sherpa-onnx-*/**
- node_modules/@napi-rs/system-ocr/**
- node_modules/@napi-rs/system-ocr-*/**
extraResources:
# Keep the updater provider in every packaged Windows app. electron-builder also
# regenerates this file during publish, using the same release configuration.
@@ -33,7 +35,6 @@ extraResources:
- mobile_daily_report.html
- mobile_daily_report_v1.html
- mobile_daily_report_v2.html
- connectors/wechat/win32-x64/**
- key/win32/x64/**
- runtime/win32/**
- wcdb/win32/x64/**
@@ -63,4 +64,6 @@ publish:
provider: github
owner: Wxw-Gu
repo: TraceMemo
releaseType: release
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
releaseType: draft
+8 -1
View File
@@ -19,6 +19,11 @@ asarUnpack:
- node_modules/silk-wasm/**
- node_modules/sherpa-onnx-node/**
- node_modules/sherpa-onnx-*/**
# System OCR(@napi-rs/system-ocr)的 native binding 必须 unpacked,否则
# macOS 的系统 OCR 会在运行时 MODULE_NOT_FOUND。平台本机的 binding 由
# scripts/after-pack.cjs 校验,外架构的同级包在 afterPack 里被裁掉。
- node_modules/@napi-rs/system-ocr/**
- node_modules/@napi-rs/system-ocr-*/**
extraResources:
# Keep the updater provider in every packaged macOS app. electron-builder also
# regenerates this file during publish, using the same release configuration.
@@ -71,4 +76,6 @@ publish:
provider: github
owner: Wxw-Gu
repo: TraceMemo
releaseType: release
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
releaseType: draft
+3 -1
View File
@@ -8,13 +8,15 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve('src/main/index.ts'),
reportTemplateTest: resolve('src/main/report-template-test-entry.ts'),
queryAgentPoc: resolve('src/main/query-agent-poc-entry.ts'),
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
},
output: {
entryFileNames: '[name].js'
},
external: ['koffi', 'sherpa-onnx-node']
external: ['koffi', 'sherpa-onnx-node', '@napi-rs/system-ocr']
}
}
},
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>
+28 -18
View File
@@ -1,6 +1,6 @@
{
"name": "tracememo",
"version": "2.2.3",
"version": "2.4.0",
"packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [
@@ -25,22 +25,27 @@
},
"main": "./out/main/index.js",
"scripts": {
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:e2e:build && playwright test",
"format": "prettier --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "npm run typecheck:node && npm run typecheck:web && npm run typecheck:test",
"typecheck:test": "node scripts/typecheck-tests.cjs",
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
"cp:env": "node scripts/ensure-env.cjs",
"prepare:env": "node scripts/ensure-env.cjs",
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
"start": "electron-vite preview",
"predev": "node -e \"require('electron')\"",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
"predev": "node scripts/ensure-electron-binary.cjs",
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
"dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev",
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"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:unit": "vitest run --config vitest.unit.config.ts",
"test:component": "vitest run --config vitest.component.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
@@ -51,26 +56,27 @@
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64",
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
"build:native-services": "npm run build:wechat-connector",
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.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:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
"build:mac:arm64": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"build:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
"release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
"release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
"release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always",
"release:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
"release:beta": "cross-env EP_PRE_RELEASE=true npm run release",
"release:stable": "npm run release",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@napi-rs/system-ocr": "1.2.0",
"@napi-rs/system-ocr-win32-x64-msvc": "1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
@@ -90,6 +96,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^10.1.0",
"css-tree": "^3.0.1",
"electron-updater": "^6.6.2",
"ffmpeg-static": "5.3.0",
"fs-extra": "^11.3.2",
@@ -97,10 +104,13 @@
"jsonrepair": "^3.15.0",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"parse5": "^8.0.0",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"sherpa-onnx-node": "1.13.3",
"silk-wasm": "^3.7.1",
"tailwind-merge": "^3.6.0",
"unzipper": "^0.12.0",
"wechat-emojis": "^1.0.2"
},
"devDependencies": {
+87 -5
View File
@@ -12,6 +12,8 @@ specifiers:
'@electron-toolkit/tsconfig': ^2.0.0
'@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@playwright/test': ^1.62.1
'@radix-ui/react-alert-dialog': ^1.1.23
'@radix-ui/react-checkbox': ^1.3.11
@@ -46,6 +48,7 @@ specifiers:
class-variance-authority: ^0.7.1
clsx: ^2.1.1
cross-env: ^10.1.0
css-tree: ^3.0.1
electron: ^43.0.0
electron-builder: ^26.0.12
electron-updater: ^6.6.2
@@ -61,6 +64,8 @@ specifiers:
jsonrepair: ^3.15.0
koffi: ^3.1.0
openai: ^6.10.0
parse5: ^8.0.0
pinyin-pro: ^3.26.0
postcss: ^8.5.26
prettier: ^3.7.4
qrcode: ^1.5.4
@@ -73,6 +78,7 @@ specifiers:
tailwindcss: 3.4.17
tailwindcss-animate: ^1.0.7
typescript: ^5.9.3
unzipper: ^0.12.0
vite: ^7.2.6
vitest: ^4.1.10
wechat-emojis: ^1.0.2
@@ -82,6 +88,8 @@ dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
@@ -101,6 +109,7 @@ dependencies:
class-variance-authority: 0.7.1
clsx: 2.1.1
cross-env: 10.1.0
css-tree: 3.2.1
electron-updater: 6.8.9
ffmpeg-static: 5.3.0
fs-extra: 11.3.2
@@ -108,10 +117,13 @@ dependencies:
jsonrepair: 3.15.0
koffi: 3.1.0
openai: 6.10.0
parse5: 8.0.1
pinyin-pro: 3.29.3
qrcode: 1.5.4
sherpa-onnx-node: 1.13.3
silk-wasm: 3.7.1
tailwind-merge: 3.6.0
unzipper: 0.12.5
wechat-emojis: 1.0.2
devDependencies:
@@ -1677,6 +1689,44 @@ packages:
- supports-color
dev: true
/@napi-rs/system-ocr/1.2.0:
resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==}
engines: {node: '>= 10'}
optionalDependencies:
'@napi-rs/system-ocr-darwin-arm64': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-arm64-msvc': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
dev: false
/@napi-rs/system-ocr-darwin-arm64/1.2.0:
resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==}
cpu: [arm64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-darwin-x64/1.2.0:
resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==}
cpu: [x64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-arm64-msvc/1.2.0:
resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==}
cpu: [arm64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-x64-msvc/1.2.0:
resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==}
cpu: [x64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@nodelib/fs.scandir/2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -3898,6 +3948,10 @@ packages:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
dev: true
/bluebird/3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
dev: false
/brace-expansion/1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
dependencies:
@@ -4350,7 +4404,6 @@ packages:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
dev: true
/css.escape/1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
@@ -4570,6 +4623,12 @@ packages:
gopd: 1.2.0
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:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
dev: true
@@ -4697,7 +4756,6 @@ packages:
/entities/8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
dev: true
/env-paths/2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
@@ -5301,6 +5359,15 @@ packages:
jsonfile: 6.2.0
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:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
engines: {node: '>=14.14'}
@@ -6326,7 +6393,6 @@ packages:
/mdn-data/2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
dev: true
/merge2/1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
@@ -6552,6 +6618,10 @@ packages:
semver: 7.7.3
dev: true
/node-int64/0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
dev: false
/node-releases/2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
dev: true
@@ -6771,7 +6841,6 @@ packages:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
dependencies:
entities: 8.0.0
dev: true
/path-exists/4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
@@ -6835,6 +6904,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/pinyin-pro/3.29.3:
resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==}
dev: false
/pirates/4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
@@ -7659,7 +7732,6 @@ packages:
/source-map-js/1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
dev: true
/source-map-support/0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
@@ -8204,6 +8276,16 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
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:
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
hasBin: true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 153 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.
+17 -9
View File
@@ -68,21 +68,29 @@
.overview {
margin-top: 2px;
}
/*
* hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。
*
* 生产导出会额外注入 `report-template-fragment-contract.ts`,其中
* `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在
* clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。
* 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px,
* 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器,
* 视觉上越过卡片内边距、压到卡片边缘之外。
*
* 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出,
* 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。
*/
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px));
grid-auto-rows: var(--tm-avatar-hero-size, 40px);
gap: 3px;
flex: 0 0 auto;
}
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
.avatar-grid.avatar-count-1 {
width: 28px;
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
grid-template-columns: var(--tm-avatar-hero-size, 40px);
}
.avatar-grid.empty-section {
display: none;
+17 -9
View File
@@ -61,21 +61,29 @@
font-size: 13px;
line-height: 1.55;
}
/*
* hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。
*
* 生产导出会额外注入 `report-template-fragment-contract.ts`,其中
* `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在
* clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。
* 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px,
* 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器,
* 视觉上越过卡片内边距、压到卡片边缘之外。
*
* 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出,
* 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。
*/
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px));
grid-auto-rows: var(--tm-avatar-hero-size, 40px);
gap: 3px;
flex: 0 0 auto;
}
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
.avatar-grid.avatar-count-1 {
width: 28px;
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
grid-template-columns: var(--tm-avatar-hero-size, 40px);
}
.avatar-grid.empty-section {
display: none;
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 @@
a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
+241 -5
View File
@@ -1,8 +1,9 @@
/* 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 path = require('node:path')
const asar = require('@electron/asar')
const { readBinaryArchitectures } = require('./binary-arch.cjs')
const REQUIRED_RUNTIME_PACKAGES = [
'@electron-toolkit/preload',
@@ -15,6 +16,15 @@ const REQUIRED_RUNTIME_PACKAGES = [
'koffi'
]
// electron-builder 26 skips macOS signing entirely when no Developer ID
// identity is configured, so an unpacked bundle can ship without a usable
// signature. macOS kills a helper whose code or signature is missing or
// modified even when SIP is disabled, which is what customers hit on newer
// macOS releases. Ad-hoc re-sign the runtime helpers and the outer bundle so
// every Mach-O verifies strictly; spctl still rejects ad-hoc code, which is
// acceptable for the SIP-disabled customer workflow.
const MACOS_HELPER_NAMES = ['xkey_helper', 'xkey_helper_4_1_13']
function getRuntimeResources(context) {
const productName = context.packager.appInfo.productFilename
return context.electronPlatformName === 'darwin'
@@ -77,11 +87,143 @@ function validateSherpaRuntime(runtimeResources, platform, arch) {
}
}
/**
* System OCR 用 native package(@napi-rs/system-ocr)。它是 external + asarUnpack,
* 打包后必须以 unpacked 形式存在,否则运行时会 MODULE_NOT_FOUND / native binding missing。
* Windows 与 macOS 都是 supported target,都要做硬校验(Linux 不是)。
*/
function systemOcrTarget(platform, arch) {
return platform === 'win32' ? `${platform}-${arch}-msvc` : `${platform}-${arch}`
}
function validateSystemOcrRuntime(runtimeResources, platform, arch) {
if (platform !== 'win32' && platform !== 'darwin') return
const target = systemOcrTarget(platform, arch)
const basePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
'@napi-rs',
'system-ocr'
)
const nativePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
'@napi-rs',
`system-ocr-${target}`
)
const requiredFiles = [
path.join(basePath, 'package.json'),
path.join(basePath, 'index.js'),
path.join(nativePath, 'package.json'),
path.join(nativePath, `system-ocr.${target}.node`)
]
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
if (missingFiles.length > 0) {
throw new Error(`Missing unpacked System OCR runtime: ${missingFiles.join(', ')}`)
}
}
function normalizeBuilderArch(arch) {
if (typeof arch === 'string') return arch
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
}
function runCodesign(args) {
execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' })
}
function isMacosCodeValid(targetPath, run = runCodesign) {
try {
run(['--verify', '--strict', targetPath])
return true
} catch {
return false
}
}
function findMacosHelperPaths(runtimeResources) {
return MACOS_HELPER_NAMES.map((name) => path.join(runtimeResources, 'resources', name)).filter(
(helperPath) => existsSync(helperPath)
)
}
function signMacosHelpers(runtimeResources, run = runCodesign) {
const helperPaths = findMacosHelperPaths(runtimeResources)
for (const helperPath of helperPaths) {
chmodSync(helperPath, 0o755)
if (!isMacosCodeValid(helperPath, run)) {
run(['--force', '--sign', '-', helperPath])
}
for (const arch of ['arm64', 'x86_64']) {
try {
run(['--verify', '--strict', '--arch', arch, helperPath])
} catch (error) {
throw new Error(
'macOS helper signature verification failed: ' +
path.basename(helperPath) +
' (' +
arch +
')',
{ cause: error }
)
}
}
}
return helperPaths
}
function signMacosAppBundle(appBundlePath, run = runCodesign) {
if (isMacosCodeValid(appBundlePath, run)) return appBundlePath
run(['--force', '--sign', '-', appBundlePath])
try {
run(['--verify', '--strict', appBundlePath])
} catch (error) {
throw new Error('macOS app bundle signature verification failed: ' + appBundlePath, {
cause: error
})
}
return appBundlePath
}
/**
* A foreign-architecture binary only fails once the user touches the feature
* that needs it, so verify the ones whose filename is shared across
* architectures (ffmpeg-static keeps a single "ffmpeg" per platform) and fail
* the build instead of shipping a broken bundle.
*/
function validateRuntimeBinaryArchitecture(filePath, platform, arch, label) {
if (platform !== 'darwin' && platform !== 'win32') return
if (arch === 'universal') return
const architectures = readBinaryArchitectures(filePath)
if (!architectures.length || architectures.includes(arch)) return
throw new Error(
`${label} is ${architectures.join('/')} but this bundle targets ${arch}: ${filePath}`
)
}
/**
* The Intel Mac key helper is an x86_64 executable that only the x64 (or
* universal) macOS bundle can run. Every other target — Apple Silicon macOS,
* Windows, Linux — would otherwise ship a ~34MB binary it can never execute,
* so it is dropped from those bundles. x64/universal builds fail fast instead
* of silently shipping an Intel Mac app that cannot read keys.
*/
function pruneIntelMacKeyTool(runtimeResources, platform, arch) {
const keyToolDirectory = path.join(runtimeResources, 'resources', 'macos-key-tool')
const usable = platform === 'darwin' && (arch === 'x64' || arch === 'universal')
if (!usable) {
rmSync(keyToolDirectory, { recursive: true, force: true })
return null
}
const helperPath = path.join(keyToolDirectory, 'intel_mac_key_helper')
if (!existsSync(helperPath)) {
throw new Error(`Missing Intel Mac key helper in a ${arch} bundle: ${helperPath}`)
}
return helperPath
}
function validateAsarRuntimeDependencies(runtimeResources) {
const asarPath = path.join(runtimeResources, 'app.asar')
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
@@ -107,22 +249,108 @@ function validateReaderSkillRuntime(runtimeResources) {
return skillPath
}
/**
* Native runtime packages are published once per platform-arch pair, and pnpm
* installs all of them, so every bundle ends up carrying the native libraries
* of every platform (measured: ~129MB of speech models plus ~16MB of koffi).
* The loaders pick their package from process.platform/arch, so the siblings
* are dead weight — drop them.
*/
// 每个条目返回 platform package 的**完整后缀**(不含 package 前缀与连字符)。
const NATIVE_RUNTIME_PACKAGES = [
{
modules: [],
prefix: 'sherpa-onnx',
platformName: (platform, arch) => `${platform === 'win32' ? 'win' : platform}-${arch}`
},
{
modules: ['@koromix'],
prefix: 'koffi',
platformName: (platform, arch) => `${platform}-${arch}`
},
{
// @napi-rs 的 platform package 目录名带 -msvc 后缀(win32-x64-msvc)。
modules: ['@napi-rs'],
prefix: 'system-ocr',
platformName: (platform, arch) => systemOcrTarget(platform, arch),
foreignPattern: /^system-ocr-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)(-msvc)?$/
}
]
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 =
runtime.foreignPattern ||
new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
removed.push(
runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name
)
}
}
return removed
}
/**
* Bundled native directories under resources/connectors are named
* "<platform>-<arch>". Cross-building both macOS architectures leaves both on
* disk, but a bundle can only execute its own, so drop the foreign ones
* instead of shipping every connector twice.
*/
function pruneForeignArchConnectors(runtimeResources, platform, arch) {
if (arch === 'universal') return []
const connectorsRoot = path.join(runtimeResources, 'resources', 'connectors')
if (!existsSync(connectorsRoot)) return []
const expected = `${platform}-${arch}`
const removed = []
for (const packageEntry of readdirSync(connectorsRoot, { withFileTypes: true })) {
if (!packageEntry.isDirectory()) continue
const packageRoot = path.join(connectorsRoot, packageEntry.name)
for (const targetEntry of readdirSync(packageRoot, { withFileTypes: true })) {
if (!targetEntry.isDirectory() || targetEntry.name === expected) continue
if (!/^[a-z0-9]+-(arm64|x64|ia32)$/.test(targetEntry.name)) continue
rmSync(path.join(packageRoot, targetEntry.name), { recursive: true, force: true })
removed.push(`${packageEntry.name}/${targetEntry.name}`)
}
}
return removed
}
exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context)
const arch = normalizeBuilderArch(context.arch)
validateAsarRuntimeDependencies(runtimeResources)
validateReaderSkillRuntime(runtimeResources)
validateSilkWasmRuntime(runtimeResources)
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
validateSherpaRuntime(
runtimeResources,
validateRuntimeBinaryArchitecture(
ffmpegPath,
context.electronPlatformName,
normalizeBuilderArch(context.arch)
arch,
'Bundled ffmpeg'
)
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
if (context.electronPlatformName === 'darwin') {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
stdio: 'ignore'
})
signMacosHelpers(runtimeResources)
const productName = context.packager.appInfo.productFilename
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
}
if (context.electronPlatformName === 'win32') {
@@ -141,7 +369,6 @@ exports.default = async function afterPack(context) {
}
return
}
}
exports.getRuntimeResources = getRuntimeResources
@@ -150,3 +377,12 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
exports.findMacosHelperPaths = findMacosHelperPaths
exports.isMacosCodeValid = isMacosCodeValid
exports.signMacosHelpers = signMacosHelpers
exports.signMacosAppBundle = signMacosAppBundle
+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()
-65
View File
@@ -1,65 +0,0 @@
/* 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 sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
function normalizePlatform(value) {
if (value === 'win32' || value === 'windows') return 'windows'
if (value === 'darwin' || value === 'macos') return 'darwin'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported connector platform: ${value}`)
}
function normalizeArch(value) {
if (value === 'x64' || value === 'amd64') return 'amd64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported connector architecture: ${value}`)
}
function detectHostArch() {
if (process.platform !== 'darwin') return process.arch
try {
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
encoding: 'utf8'
}).trim()
return arm64Supported === '1' ? 'arm64' : process.arch
} catch {
return process.arch
}
}
function parseTargets() {
const platformArg = process.argv.indexOf('--platform')
const archArg = process.argv.indexOf('--arch')
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
return platforms.flatMap((platform) =>
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
)
}
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
}
for (const target of parseTargets()) {
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
const outputDir = path.join(outputRoot, directoryName)
const outputPath = path.join(
outputDir,
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
)
fs.rmSync(outputDir, { recursive: true, force: true })
fs.mkdirSync(outputDir, { recursive: true })
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
cwd: sourceDir,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
stdio: 'inherit'
})
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
}
+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 = {
REPORT_TITLE: title,
REPORT_DATE: reportDate,
DATE_RANGE: '今天',
DATE_RANGE: '今日',
TIME_SPAN: statValues[2] || dateTimeRange,
HERO_SUMMARY: overview,
HERO_TAKEAWAY: '',
+32 -3
View File
@@ -1,6 +1,7 @@
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const { readBinaryArchitectures } = require('./binary-arch.cjs')
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
@@ -24,6 +25,31 @@ function readOption(name, fallback) {
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
}
function ffmpegExecutableName(targetPlatform) {
return targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
}
/**
* ffmpeg-static keeps a single binary per platform ("ffmpeg" everywhere except
* Windows), so an arm64 and an x64 macOS checkout cannot coexist in
* node_modules. Its installer also exits early whenever the file already
* exists, so a binary left over from the other architecture would be packed
* silently. Check the real architecture and drop the file when it differs, so
* the caller re-downloads the requested one.
*/
function ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch) {
if (!fs.existsSync(ffmpegPath)) return 'missing'
const architectures = readBinaryArchitectures(ffmpegPath)
if (architectures.includes(targetArch)) return 'match'
console.log(
`[prepare-electron-runtime] ffmpeg-static is ${
architectures.join('/') || 'not a native binary'
} but ${targetPlatform}-${targetArch} was requested; replacing it`
)
fs.rmSync(ffmpegPath, { force: true })
return 'replaced'
}
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
let packageRoot = ''
try {
@@ -31,10 +57,11 @@ function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = pr
} catch {
return
}
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const executable = ffmpegExecutableName(targetPlatform)
const ffmpegPath = path.join(packageRoot, executable)
const architectureState = ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch)
if (!fs.existsSync(ffmpegPath)) {
if (architectureState !== 'match') {
const installScript = path.join(packageRoot, 'install.js')
console.log(
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
@@ -83,4 +110,6 @@ function main() {
}
}
main()
if (require.main === module) main()
module.exports = { ensureFfmpegArchitecture, ffmpegExecutableName, prepareFfmpegRuntime }
+259 -23
View File
@@ -86,41 +86,115 @@ for (const required of [executable, script, config]) {
function patchPerSendPayload(scriptPath) {
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 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);
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
source = source
.replace(
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
'var triggerX1Payload;\nvar triggerX0;'
)
.replace(activeSend, upstreamSend)
.replace(
' MMStartTask(triggerX0, activeTriggerX1Payload);',
' MMStartTask(triggerX0, triggerX1Payload);'
)
.replace(
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
)
.replace(
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
}
if (!source.includes(declarations) || !source.includes(originalSend)) {
throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
function patchSendContextCapture(scriptPath, strict = true) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('function isLikelySendContext(')) return
const original = `function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) {
return
}
triggerX0 = this.context.x0;
triggerX1Payload = this.context.x1;
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
try {
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
var manager = readPointerIfReadable(candidateX0.add(0x18));
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
return !manager.equals(ptr(0));
} catch (e) {
console.error("[debug] StartTask candidate inspect failed: " + e);
return false;
}
}
function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) return;
var candidateX0 = this.context.x0;
var candidateX1 = this.context.x1;
if (!isLikelySendContext(candidateX0, candidateX1)) return;
triggerX0 = candidateX0;
triggerX1Payload = candidateX1;
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
if (!source.includes(original)) {
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
return
}
source = source.replace(original, patched)
fs.writeFileSync(scriptPath, source)
}
function patchVoiceAudioBuffer(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
if (!source.includes(staticAllocation)) {
throw new Error('无法定位 wechat_chatter 语音缓冲区')
}
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
source = source.replace(
' MMStartTask(triggerX0, triggerX1Payload);',
' MMStartTask(triggerX0, activeTriggerX1Payload);'
staticAllocation,
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
)
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
}
source = source.replace(
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
)
source = source.replace(
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
audioLengthMarker,
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
}
function patchImageHookReadiness(scriptPath) {
@@ -146,6 +220,163 @@ function patchImageHookReadiness(scriptPath) {
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
}
// Backport of wechat_chatter PR #36 by @Leslielu:
// https://github.com/yincongcyincong/wechat_chatter/pull/36
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
function patchCdnColdStart(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('function resolveCdnManager()')) return
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
source = source.replace(initAddresses, patchedInitAddresses)
const downloadChunkEnd = `}
function fillUploadX1AndStart`
const resolver = `}
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
function resolveCdnManager() {
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
return ptr(0);
}
try {
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
var strDefault = Memory.alloc(24);
strDefault.writeUtf8String("default");
strDefault.add(0x17).writeU8(7);
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
var svc = getService(strDefault);
if (!isReadablePointer(svc)) {
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
return ptr(0);
}
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
var ctx = getCtx(svc);
if (!isReadablePointer(ctx)) {
console.error("[!] CdnManager getter 返回不可读: " + ctx);
return ptr(0);
}
var mgr = readPointerIfReadable(ctx.add(0x40));
if (!isReadablePointer(mgr)) {
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
return ptr(0);
}
return mgr;
} catch (e) {
console.error("[!] resolveCdnManager 异常: " + e);
return ptr(0);
}
}
function ensureCdnManagerX0() {
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
}
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = uploadGlobalX0;
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
}
if (uploadGlobalX0.equals(ptr(0))) {
var mgr = resolveCdnManager();
if (!mgr.equals(ptr(0))) {
uploadGlobalX0 = mgr;
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = mgr;
}
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
}
}
return !uploadGlobalX0.equals(ptr(0));
}
function fillUploadX1AndStart`
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
source = source.replace(downloadChunkEnd, resolver)
const declarations = 'var uploadImageAddr;\n'
const patchedDeclarations =
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
source = source.replace(declarations, patchedDeclarations)
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
source = source.replace(uploadGuard, patchedUploadGuard)
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
source = source.replace(voiceGuard, patchedVoiceGuard)
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
downloadGlobalX0 = capturedUploadX0;
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
}`
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
source = source.replace(uploadHook, patchedUploadHook)
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
}`
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
source = source.replace(downloadHook, patchedDownloadHook)
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0) {`
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (!downloadGlobalX0) {`
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
source = source.replace(downloadGuard, patchedDownloadGuard)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
}
function patchCdnColdStartConfig(configPath) {
const source = fs.readFileSync(configPath, 'utf8')
let config
try {
config = JSON.parse(source)
} catch {
throw new Error('4.1.11.53 版本配置不是有效 JSON')
}
config.cdnGetServiceAddr = '0x50a15d0'
config.cdnManagerGetterAddr = '0x5259290'
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
}
function patchWechatCoreModuleBase(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return
@@ -182,7 +413,8 @@ function addModifiedWorkNotice(scriptPath) {
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
* 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.
*/
@@ -194,7 +426,11 @@ function addModifiedWorkNotice(scriptPath) {
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchSendContextCapture(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script)
patchCdnColdStartConfig(config)
patchCdnColdStart(script)
addModifiedWorkNotice(script)
fs.chmodSync(executable, 0o755)
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 }
+11 -4
View File
@@ -24,10 +24,17 @@ const avatarSvg = (label, color) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
).toString('base64')}`
const localImagePath = '/Users/user/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/fixture_account_1a2b/temp/RWTemp/2026-07/fixture-image-hash.png'
const sampleImage = fs.existsSync(localImagePath)
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
: avatarSvg('图', '#dbeafe')
/**
* 可选的本地样例图(用于人工核对图片区块的排版)。
*
* 走环境变量传入,**不要在源码里写本机路径** —— 微信数据目录会连带暴露
* 系统用户名与账号目录名。不传就退回内置的 SVG 头像占位。
*/
const localImagePath = process.env.REPORT_FIXTURE_IMAGE || ''
const sampleImage =
localImagePath && fs.existsSync(localImagePath)
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
: avatarSvg('图', '#dbeafe')
const avatars = {
阿宇: avatarSvg('宇', '#dcfce7'),
+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)
+140
View File
@@ -0,0 +1,140 @@
/*
* 测试文件的类型检查棘轮(ratchet)。
*
* 背景:`tsconfig.node.json` / `tsconfig.web.json` 的 include 都不含 `tests/`,
* 所以测试里的类型错误对 `pnpm typecheck` 与 CI 完全不可见——已经积累了一批历史债。
*
* 策略:**不阻塞既有债,但禁止新增**。
* - 基线按「文件 → 错误数」记录,而不是只记总数:
* 否则在 A 文件修掉 1 条、同时在 B 文件新增 1 条会互相抵消,棘轮形同虚设。
* - 某个文件的错误数超过基线即失败;新增了带类型错误的文件同样失败。
* - 需要主动下调基线时用 `--update`(只在确实修好了错误之后)。
*
* 用法:
* node scripts/typecheck-tests.cjs # 校验
* node scripts/typecheck-tests.cjs --update # 用当前结果重写基线
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
const { spawnSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const configPath = path.join(projectRoot, 'tsconfig.test.json')
const baselinePath = path.join(projectRoot, 'tests', 'typecheck-baseline.json')
const ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/
const MAX_REPORTED = 20
function runTypeScript() {
// 直接用本地 typescript 包,避免依赖 node_modules/.bin 在各平台的差异。
const tscPath = require.resolve('typescript/bin/tsc')
const result = spawnSync(
process.execPath,
[tscPath, '--noEmit', '--pretty', 'false', '-p', configPath],
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
)
if (result.error) throw result.error
return `${result.stdout || ''}${result.stderr || ''}`
}
function relative(file) {
const rel = path.relative(projectRoot, path.resolve(projectRoot, file))
return rel.split(path.sep).join('/')
}
/** 解析出「文件 → 错误数」与「文件 → 错误信息列表」。 */
function collectErrors(output) {
const counts = new Map()
const details = new Map()
for (const line of output.split('\n')) {
const match = ERROR_LINE.exec(line.trim())
if (!match) continue
const file = relative(match[1])
counts.set(file, (counts.get(file) ?? 0) + 1)
if (!details.has(file)) details.set(file, [])
if (details.get(file).length < 3) details.get(file).push(`${match[2]}:${match[3]} ${match[4]}`)
}
return { counts, details }
}
function readBaseline() {
try {
const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
if (parsed && typeof parsed.files === 'object' && parsed.files !== null) return parsed
} catch {
// 基线缺失或损坏时按「空基线」处理,会在下面明确报错提示。
}
return null
}
function total(counts) {
let sum = 0
for (const value of counts.values()) sum += value
return sum
}
function main() {
if (!fs.existsSync(configPath)) {
console.error(`[typecheck:test] 缺少 ${path.relative(projectRoot, configPath)}`)
process.exit(1)
}
const { counts, details } = collectErrors(runTypeScript())
if (process.argv.includes('--update')) {
const files = Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
const payload = {
note: '测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。',
total: total(counts),
files
}
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
fs.writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
console.log(
`[typecheck:test] 基线已更新:${payload.total} 个错误 / ${Object.keys(files).length} 个文件`
)
return
}
const baseline = readBaseline()
if (!baseline) {
console.error(
`[typecheck:test] 找不到基线 ${path.relative(projectRoot, baselinePath)}。\n` +
' 首次启用请运行:node scripts/typecheck-tests.cjs --update'
)
process.exit(1)
}
const regressions = []
for (const [file, count] of counts) {
const allowed = baseline.files[file] ?? 0
if (count > allowed) regressions.push({ file, count, allowed })
}
const now = total(counts)
const baselineTotal = Number(baseline.total) || 0
const improved = baselineTotal - now
if (regressions.length > 0) {
console.error('[typecheck:test] 测试文件出现新的类型错误 ❌')
console.error(` 基线 ${baselineTotal} → 当前 ${now}(+${now - baselineTotal})\n`)
let printed = 0
for (const item of regressions) {
console.error(` ${item.file} ${item.allowed} → ${item.count}`)
for (const line of details.get(item.file) ?? []) {
if (printed >= MAX_REPORTED) break
console.error(` ${line}`)
printed += 1
}
}
console.error('\n 修好之后用 --update 下调基线(不要为了过检查而放宽它)。')
process.exit(1)
}
console.log(
`[typecheck:test] PASS ✅ 当前 ${now} 个既有类型错误 / ${counts.size} 个文件` +
(improved > 0 ? `(比基线少 ${improved} 个,可运行 --update 下调)` : '')
)
console.log(' 注意:这是棘轮,只保证「不新增」。修完历史债后可改为阻断式检查。')
}
main()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 fastclaw-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-25
View File
@@ -1,25 +0,0 @@
# TraceMemo WeChat Connector
This repository-local service provides the minimal WeChat bridge required by TraceMemo:
- QR-code login with a single persisted credential
- account discovery
- inbound long polling and authenticated webhook delivery
- local HTTP health and send endpoints
- text and local/remote media sending
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
## Commands
```bash
go run . login --json
go run . accounts --json
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
```
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. This legacy directory name is intentionally retained so upgrades can reuse existing accounts. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
## Attribution
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). TraceMemo-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding TraceMemo project.
-135
View File
@@ -1,135 +0,0 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
)
// Server provides an HTTP API for sending messages.
type Server struct {
clients []*ilink.Client
addr string
}
// NewServer creates an API server.
func NewServer(clients []*ilink.Client, addr string) *Server {
if addr == "" {
addr = "127.0.0.1:18011"
}
return &Server{clients: clients, addr: addr}
}
// SendRequest is the JSON body for POST /api/send.
type SendRequest struct {
AccountID string `json:"account_id,omitempty"`
To string `json:"to"`
Text string `json:"text,omitempty"`
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
}
// Run starts the HTTP server. Blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
log.Printf("[api] listening on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" {
http.Error(w, `"to" is required`, http.StatusBadRequest)
return
}
if req.Text == "" && req.MediaURL == "" {
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
return
}
if len(s.clients) == 0 {
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
return
}
client := s.clientForAccount(req.AccountID)
if client == nil {
http.Error(w, "requested account is not available", http.StatusNotFound)
return
}
ctx := r.Context()
// Send text if provided
if req.Text != "" {
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
log.Printf("[api] send text failed: %v", err)
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
// Extract and send any markdown images embedded in text
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
log.Printf("[api] send extracted image failed: %v", err)
} else {
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
}
}
}
// Send media if provided
if req.MediaURL != "" {
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
log.Printf("[api] send media failed: %v", err)
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) clientForAccount(accountID string) *ilink.Client {
if accountID == "" {
return s.clients[0]
}
for _, client := range s.clients {
if client.BotID() == accountID {
return client
}
}
return nil
}
@@ -1,20 +0,0 @@
package api
import (
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
if got := server.clientForAccount("bot-new"); got != newClient {
t.Fatal("clientForAccount did not select the requested account")
}
if got := server.clientForAccount("missing"); got != nil {
t.Fatal("clientForAccount should reject an unknown account")
}
}
-8
View File
@@ -1,8 +0,0 @@
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
go 1.23.0
require (
github.com/google/uuid v1.6.0
rsc.io/qr v0.2.0
)
-4
View File
@@ -1,4 +0,0 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
-236
View File
@@ -1,236 +0,0 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
statusWait = "wait"
statusScanned = "scaned"
statusConfirmed = "confirmed"
statusExpired = "expired"
)
// FetchQRCode retrieves a new QR code for login.
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
c := NewUnauthenticatedClient()
var resp QRCodeResponse
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
return nil, fmt.Errorf("fetch QR code: %w", err)
}
return &resp, nil
}
// PollQRStatus polls for QR code scan status until confirmed or expired.
// It calls onStatus for each status change so the caller can display progress.
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
c := NewUnauthenticatedClient()
url := qrStatusURL + qrcode
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
var resp QRStatusResponse
err := c.doGet(pollCtx, url, &resp)
cancel()
if err != nil {
// Timeout is normal for long-poll, retry
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
if onStatus != nil {
onStatus(resp.Status)
}
switch resp.Status {
case statusConfirmed:
creds := &Credentials{
BotToken: resp.BotToken,
ILinkBotID: resp.ILinkBotID,
BaseURL: resp.BaseURL,
ILinkUserID: resp.ILinkUserID,
}
return creds, nil
case statusExpired:
return nil, fmt.Errorf("QR code expired")
case statusWait, statusScanned:
// Continue polling
default:
// Unknown status, continue
}
}
}
func accountsDir(rootName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
}
// AccountsDir returns the TraceMemo directory where new credentials are stored.
func AccountsDir() (string, error) {
return accountsDir(".tracememo")
}
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
func LegacyAccountsDir() (string, error) {
return accountsDir(".wechatexplorer")
}
func accountDirectoryForID(accountID string) (string, error) {
current, err := AccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
return current, nil
}
legacy, err := LegacyAccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
return legacy, nil
}
return current, nil
}
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
func NormalizeAccountID(raw string) string {
s := raw
for _, ch := range []string{"@", ".", ":"} {
s = filepath.Clean(s)
s = replaceAll(s, ch, "-")
}
return s
}
func replaceAll(s, old, new string) string {
for {
i := indexOf(s, old)
if i < 0 {
return s
}
s = s[:i] + new + s[i+len(old):]
}
}
func indexOf(s, sub string) int {
for i := range s {
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// SaveCredentials saves the latest credentials and removes older accounts.
// The new credential is written first so a failed login never destroys the
// previously working credential.
func SaveCredentials(creds *Credentials) error {
dir, err := AccountsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create accounts dir: %w", err)
}
id := NormalizeAccountID(creds.ILinkBotID)
path := filepath.Join(dir, id+".json")
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("prune old credentials: %w", err)
}
keepPrefix := id + "."
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
continue
}
if filepath.Ext(entry.Name()) != ".json" {
continue
}
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
}
}
return nil
}
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read accounts dir: %w", err)
}
var result []*Credentials
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var creds Credentials
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
result = append(result, &creds)
}
}
return result, nil
}
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
// untouched WechatExplorer directory for one-version upgrade compatibility.
func LoadAllCredentials() ([]*Credentials, error) {
current, err := AccountsDir()
if err != nil {
return nil, err
}
credentials, err := loadCredentialsFromDir(current)
if err != nil || len(credentials) > 0 {
return credentials, err
}
legacy, err := LegacyAccountsDir()
if err != nil {
return nil, err
}
return loadCredentialsFromDir(legacy)
}
// CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) {
return AccountsDir()
}
@@ -1,80 +0,0 @@
package ilink
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Setenv("HOME", t.TempDir())
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil {
t.Fatal(err)
}
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if err := SaveCredentials(latest); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
t.Fatalf("old sync state still exists: %v", err)
}
}
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts")
if dir != want {
t.Fatalf("AccountsDir() = %q, want %q", dir, want)
}
}
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
legacyDir, err := LegacyAccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
t.Fatal(err)
}
legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(legacyDir); err != nil {
t.Fatalf("legacy directory changed or removed: %v", err)
}
}
-218
View File
@@ -1,218 +0,0 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://ilinkai.weixin.qq.com"
longPollTimeout = 35 * time.Second
sendTimeout = 15 * time.Second
)
// Client is an iLink HTTP API client.
type Client struct {
baseURL string
botToken string
botID string
httpClient *http.Client
wechatUIN string
}
// NewClient creates a new iLink API client.
func NewClient(creds *Credentials) *Client {
baseURL := creds.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: baseURL,
botToken: creds.BotToken,
botID: creds.ILinkBotID,
httpClient: &http.Client{},
wechatUIN: generateWechatUIN(),
}
}
// NewUnauthenticatedClient creates a client without credentials for login flow.
func NewUnauthenticatedClient() *Client {
return &Client{
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 40 * time.Second},
wechatUIN: generateWechatUIN(),
}
}
// BotID returns the bot's user ID.
func (c *Client) BotID() string {
return c.botID
}
// GetUpdates performs a long-poll for new messages.
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
reqBody := GetUpdatesRequest{
GetUpdatesBuf: buf,
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
}
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
defer cancel()
var resp GetUpdatesResponse
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendMessage sends a message through iLink.
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp SendMessageResponse
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetConfig fetches bot config for a user (includes typing_ticket).
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := GetConfigRequest{
ILinkUserID: userID,
ContextToken: contextToken,
BaseInfo: BaseInfo{},
}
var resp GetConfigResponse
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendTyping sends a typing indicator to a user.
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := SendTypingRequest{
ILinkUserID: userID,
TypingTicket: typingTicket,
Status: status,
BaseInfo: BaseInfo{},
}
var resp SendTypingResponse
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
return err
}
if resp.Ret != 0 {
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
return nil
}
// GetUploadURL gets a pre-signed CDN upload URL for media files.
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp GetUploadURLResponse
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BaseURL returns the base URL for CDN operations.
func (c *Client) BaseURL() string {
return c.baseURL
}
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("Authorization", "Bearer "+c.botToken)
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
}
func generateWechatUIN() string {
var n uint32
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
s := fmt.Sprintf("%d", n)
return base64.StdEncoding.EncodeToString([]byte(s))
}
-181
View File
@@ -1,181 +0,0 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
const (
maxConsecutiveFailures = 5
initialBackoff = 3 * time.Second
maxBackoff = 60 * time.Second
sessionExpiredBackoff = 5 * time.Second
errCodeSessionExpired = -14
)
// MessageHandler is called for each received message.
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
// Monitor manages the long-poll loop for receiving messages.
type Monitor struct {
client *Client
handler MessageHandler
getUpdatesBuf string
bufPath string
failures int
lastActivity time.Time
}
// NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
accountID := NormalizeAccountID(client.BotID())
accountsRoot, err := accountDirectoryForID(accountID)
if err != nil {
return nil, err
}
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
m := &Monitor{
client: client,
handler: handler,
bufPath: bufPath,
lastActivity: time.Now(),
}
m.loadBuf()
return m, nil
}
// Run starts the long-poll loop. It blocks until ctx is cancelled.
// Automatically recovers from errors with exponential backoff.
func (m *Monitor) Run(ctx context.Context) error {
log.Println("[monitor] starting long-poll loop")
for {
select {
case <-ctx.Done():
log.Println("[monitor] shutting down")
return ctx.Err()
default:
}
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
m.failures++
backoff := m.calcBackoff()
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
m.failures, maxConsecutiveFailures, backoff, err)
if m.failures == maxConsecutiveFailures {
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Reset failure counter on any successful response
m.failures = 0
m.lastActivity = time.Now()
// Session expired — reset sync buf and reconnect silently
if resp.ErrCode == errCodeSessionExpired {
if m.getUpdatesBuf != "" {
log.Printf("[monitor] session expired, resetting sync buf")
m.getUpdatesBuf = ""
m.saveBuf()
} else {
// Sync buf already empty but still getting session expired:
// the bot token itself has expired. The user needs to re-login.
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.")
}
select {
case <-time.After(sessionExpiredBackoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Other server errors
if resp.Ret != 0 && resp.ErrCode != 0 {
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
continue
}
// Update buf for next poll
if resp.GetUpdatesBuf != "" {
m.getUpdatesBuf = resp.GetUpdatesBuf
m.saveBuf()
}
// Process messages concurrently — don't block the poll loop
for _, msg := range resp.Msgs {
go m.handler(ctx, m.client, msg)
}
}
}
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
func (m *Monitor) calcBackoff() time.Duration {
d := initialBackoff
for i := 1; i < m.failures; i++ {
d *= 2
if d > maxBackoff {
return maxBackoff
}
}
return d
}
type syncData struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
func (m *Monitor) loadBuf() {
data, err := os.ReadFile(m.bufPath)
if err != nil {
return
}
var s syncData
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
m.getUpdatesBuf = s.GetUpdatesBuf
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
}
}
func (m *Monitor) saveBuf() {
dir := filepath.Dir(m.bufPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("[monitor] failed to create buf dir: %v", err)
return
}
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
log.Printf("[monitor] failed to save buf: %v", err)
}
}
// FormatMessageSummary returns a short description of a message for logging.
func FormatMessageSummary(msg WeixinMessage) string {
text := ""
for _, item := range msg.ItemList {
if item.Type == ItemTypeText && item.TextItem != nil {
text = item.TextItem.Text
break
}
}
if len(text) > 50 {
text = text[:50] + "..."
}
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
}
-219
View File
@@ -1,219 +0,0 @@
package ilink
// Message types
const (
MessageTypeNone = 0
MessageTypeUser = 1
MessageTypeBot = 2
)
// Message states
const (
MessageStateNew = 0
MessageStateGenerating = 1
MessageStateFinish = 2
)
// Item types
const (
ItemTypeNone = 0
ItemTypeText = 1
ItemTypeImage = 2
ItemTypeVoice = 3
ItemTypeFile = 4
ItemTypeVideo = 5
)
// QRCodeResponse is the response from get_bot_qrcode.
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// QRStatusResponse is the response from get_qrcode_status.
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// Credentials stores login session data.
type Credentials struct {
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// BaseInfo is included in request bodies.
type BaseInfo struct {
ChannelVersion string `json:"channel_version,omitempty"`
}
// GetUpdatesRequest is the body for getupdates.
type GetUpdatesRequest struct {
GetUpdatesBuf string `json:"get_updates_buf"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUpdatesResponse is the response from getupdates.
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
}
// WeixinMessage represents a message from WeChat.
type WeixinMessage struct {
Seq int `json:"seq,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// MessageItem is a single item in a message.
type MessageItem struct {
Type int `json:"type"`
TextItem *TextItem `json:"text_item,omitempty"`
ImageItem *ImageItem `json:"image_item,omitempty"`
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
VideoItem *VideoItem `json:"video_item,omitempty"`
FileItem *FileItem `json:"file_item,omitempty"`
}
// CDN media type constants.
const (
CDNMediaTypeImage = 1
CDNMediaTypeVideo = 2
CDNMediaTypeFile = 3
)
// GetUploadURLRequest is the body for getuploadurl.
type GetUploadURLRequest struct {
FileKey string `json:"filekey"`
MediaType int `json:"media_type"`
ToUserID string `json:"to_user_id"`
RawSize int `json:"rawsize"`
RawFileMD5 string `json:"rawfilemd5"`
FileSize int `json:"filesize"`
NoNeedThumb bool `json:"no_need_thumb"`
AESKey string `json:"aeskey"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUploadURLResponse is the response from getuploadurl.
type GetUploadURLResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
UploadParam string `json:"upload_param"`
UploadFullURL string `json:"upload_full_url,omitempty"`
}
// TextItem holds text content.
type TextItem struct {
Text string `json:"text"`
}
// MediaInfo holds CDN media reference for uploaded files.
type MediaInfo struct {
EncryptQueryParam string `json:"encrypt_query_param"`
AESKey string `json:"aes_key"` // base64-encoded
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
}
// VoiceItem holds voice content.
type VoiceItem struct {
Media *MediaInfo `json:"media,omitempty"`
VoiceSize int `json:"voice_size,omitempty"`
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
BitsPerSample int `json:"bits_per_sample,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` // Hz
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
}
// ImageItem holds image content.
type ImageItem struct {
URL string `json:"url,omitempty"`
Media *MediaInfo `json:"media,omitempty"`
MidSize int `json:"mid_size,omitempty"` // ciphertext size
}
// VideoItem holds video content.
type VideoItem struct {
Media *MediaInfo `json:"media,omitempty"`
VideoSize int `json:"video_size,omitempty"`
}
// FileItem holds file content.
type FileItem struct {
Media *MediaInfo `json:"media,omitempty"`
FileName string `json:"file_name,omitempty"`
Len string `json:"len,omitempty"` // plaintext size as string
}
// SendMessageRequest is the body for sendmessage.
type SendMessageRequest struct {
Msg SendMsg `json:"msg"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendMsg is the message payload for sending.
type SendMsg struct {
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
ClientID string `json:"client_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// SendMessageResponse is the response from sendmessage.
type SendMessageResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
// Typing status constants.
const (
TypingStatusTyping = 1
TypingStatusCancel = 2
)
// GetConfigRequest is the body for getconfig.
type GetConfigRequest struct {
ILinkUserID string `json:"ilink_user_id"`
ContextToken string `json:"context_token,omitempty"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetConfigResponse is the response from getconfig.
type GetConfigResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
}
// SendTypingRequest is the body for sendtyping.
type SendTypingRequest struct {
ILinkUserID string `json:"ilink_user_id"`
TypingTicket string `json:"typing_ticket"`
Status int `json:"status"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendTypingResponse is the response from sendtyping.
type SendTypingResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
-200
View File
@@ -1,200 +0,0 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
"rsc.io/qr"
)
type loginEvent struct {
Status string `json:"status"`
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
AccountID string `json:"account_id,omitempty"`
WeChatUserID string `json:"wechat_user_id,omitempty"`
}
type accountSummary struct {
AccountID string `json:"account_id"`
WeChatUserID string `json:"wechat_user_id"`
}
func main() {
if len(os.Args) < 2 {
fatal(errors.New("expected one of: login, accounts, start"))
}
var err error
switch os.Args[1] {
case "login":
err = runLogin(os.Args[2:])
case "accounts":
err = runAccounts(os.Args[2:])
case "start":
err = runStart(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
}
func runLogin(args []string) error {
flags := flag.NewFlagSet("login", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
if err := flags.Parse(args); err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
creds, err := login(ctx, *jsonOutput)
if err != nil {
return err
}
if !*jsonOutput {
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
}
return nil
}
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
qrResponse, err := ilink.FetchQRCode(ctx)
if err != nil {
return nil, err
}
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
if err != nil {
return nil, fmt.Errorf("encode QR image: %w", err)
}
emit := func(event loginEvent) {
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(event)
}
}
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
lastStatus := ""
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
if status != lastStatus {
lastStatus = status
emit(loginEvent{Status: status})
}
})
if err != nil {
return nil, err
}
if err := ilink.SaveCredentials(creds); err != nil {
return nil, fmt.Errorf("save credentials: %w", err)
}
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
return creds, nil
}
func runAccounts(args []string) error {
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "print JSON")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
items := make([]accountSummary, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
}
if *jsonOutput {
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
}
for _, item := range items {
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
}
return nil
}
func runStart(args []string) error {
flags := flag.NewFlagSet("start", flag.ContinueOnError)
_ = flags.Bool("foreground", false, "kept for host compatibility")
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
accountID := flags.String("account-id", "", "account to start")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
if len(accounts) == 0 {
return errors.New("no connected WeChat account; scan a QR code first")
}
selected := accounts[len(accounts)-1]
if *accountID != "" {
selected = nil
for _, account := range accounts {
if account.ILinkBotID == *accountID {
selected = account
break
}
}
if selected == nil {
return fmt.Errorf("account %q not found", *accountID)
}
}
ctx, cancel := signalContext()
defer cancel()
client := ilink.NewClient(selected)
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
if webhookURL != "" {
webhook.Dispatch(messageContext, source, message)
}
})
if err != nil {
return err
}
var wait sync.WaitGroup
wait.Add(2)
go func() {
defer wait.Done()
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[api] stopped: %v", err)
cancel()
}
}()
go func() {
defer wait.Done()
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[monitor] stopped: %v", err)
cancel()
}
}()
wait.Wait()
return nil
}
-232
View File
@@ -1,232 +0,0 @@
package messaging
import (
"bytes"
"context"
"crypto/aes"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// UploadedFile holds the result of a CDN upload.
type UploadedFile struct {
DownloadParam string // encrypted query param for download
AESKeyHex string // hex-encoded AES key
FileSize int // plaintext size
CipherSize int // ciphertext size
}
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
// Generate random filekey and AES key
filekey := make([]byte, 16)
aeskey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return nil, fmt.Errorf("generate filekey: %w", err)
}
if _, err := rand.Read(aeskey); err != nil {
return nil, fmt.Errorf("generate aeskey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
aeskeyHex := hex.EncodeToString(aeskey)
// Calculate MD5 of plaintext
hash := md5.Sum(data)
rawMD5 := hex.EncodeToString(hash[:])
// Calculate ciphertext size (PKCS7 padding)
cipherSize := aesECBPaddedSize(len(data))
// Get upload URL from iLink API
uploadReq := &ilink.GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: mediaType,
ToUserID: toUserID,
RawSize: len(data),
RawFileMD5: rawMD5,
FileSize: cipherSize,
NoNeedThumb: true,
AESKey: aeskeyHex,
BaseInfo: ilink.BaseInfo{},
}
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
if err != nil {
return nil, fmt.Errorf("get upload URL: %w", err)
}
if uploadResp.Ret != 0 {
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
}
// Encrypt data with AES-128-ECB
encrypted, err := encryptAESECB(data, aeskey)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
if cdnURL == "" {
if uploadResp.UploadParam == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
}
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
}
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
if err != nil {
return nil, fmt.Errorf("CDN upload: %w", err)
}
return &UploadedFile{
DownloadParam: downloadParam,
AESKeyHex: aeskeyHex,
FileSize: len(data),
CipherSize: cipherSize,
}, nil
}
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
func AESKeyToBase64(hexKey string) string {
return base64.StdEncoding.EncodeToString([]byte(hexKey))
}
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
// Decode AES key: base64 -> hex string -> raw bytes
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode AES key base64: %w", err)
}
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
if err != nil {
return nil, fmt.Errorf("decode AES key hex: %w", err)
}
// Download encrypted data from CDN
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
cdnBaseURL, url.QueryEscape(encryptQueryParam))
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download from CDN: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
}
encrypted, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CDN response: %w", err)
}
// Decrypt AES-128-ECB
return decryptAESECB(encrypted, aesKey)
}
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Remove PKCS7 padding
if len(plaintext) == 0 {
return plaintext, nil
}
padLen := int(plaintext[len(plaintext)-1])
if padLen > aes.BlockSize || padLen == 0 {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
return plaintext[:len(plaintext)-padLen], nil
}
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
}
downloadParam := resp.Header.Get("X-Encrypted-Param")
if downloadParam == "" {
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
}
return downloadParam, nil
}
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7 padding
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
// ECB mode: encrypt each block independently
encrypted := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return encrypted, nil
}
func aesECBPaddedSize(plaintextSize int) int {
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
}
@@ -1,121 +0,0 @@
package messaging
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const (
webhookAttempts = 3
webhookTimeout = 5 * time.Second
)
type InboundWebhook struct {
url string
token string
client *http.Client
}
type inboundWebhookPayload struct {
AccountID string `json:"account_id"`
FromUserID string `json:"from_user_id"`
MessageID int64 `json:"message_id"`
MessageType int `json:"message_type"`
Items []inboundWebhookItem `json:"items"`
ReceivedAt time.Time `json:"received_at"`
}
type inboundWebhookItem struct {
Type int `json:"type"`
Text string `json:"text,omitempty"`
}
func NewInboundWebhook(url, token string) *InboundWebhook {
return &InboundWebhook{
url: strings.TrimSpace(url),
token: token,
client: &http.Client{Timeout: webhookTimeout},
}
}
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
payload := normalizeInboundMessage(client.BotID(), msg)
go func() {
if err := w.deliver(ctx, payload); err != nil {
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
}
}()
}
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode payload: %w", err)
}
var lastErr error
for attempt := 1; attempt <= webhookAttempts; attempt++ {
if attempt > 1 {
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
if reqErr != nil {
return fmt.Errorf("create request: %w", reqErr)
}
req.Header.Set("Content-Type", "application/json")
if w.token != "" {
req.Header.Set("Authorization", "Bearer "+w.token)
}
resp, doErr := w.client.Do(req)
if doErr != nil {
lastErr = doErr
continue
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
break
}
}
return lastErr
}
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
for _, item := range msg.ItemList {
normalized := inboundWebhookItem{Type: item.Type}
if item.TextItem != nil {
normalized.Text = item.TextItem.Text
} else if item.VoiceItem != nil {
normalized.Text = item.VoiceItem.Text
}
items = append(items, normalized)
}
return inboundWebhookPayload{
AccountID: accountID,
FromUserID: msg.FromUserID,
MessageID: msg.MessageID,
MessageType: msg.MessageType,
Items: items,
ReceivedAt: time.Now().UTC(),
}
}
@@ -1,75 +0,0 @@
package messaging
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
var got inboundWebhookPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "secret")
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
}))
if err != nil {
t.Fatalf("deliver: %v", err)
}
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
t.Fatalf("payload = %#v", got)
}
}
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
http.Error(w, "temporary", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
t.Fatalf("deliver: %v", err)
}
if calls.Load() != 3 {
t.Fatalf("calls = %d, want 3", calls.Load())
}
}
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
t.Fatal("deliver error = nil")
}
if calls.Load() != 1 {
t.Fatalf("calls = %d, want 1", calls.Load())
}
}
@@ -1,103 +0,0 @@
package messaging
import (
"regexp"
"strings"
)
var (
// Code blocks: strip fences, keep code content
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
// Inline code: strip backticks, keep content
reInlineCode = regexp.MustCompile("`([^`]+)`")
// Images: remove entirely
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
// Links: keep display text only
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
// Table separator rows: remove
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
// Table rows: convert pipe-delimited to space-delimited
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
// Headers: remove # prefix
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
// Bold: **text** or __text__
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
// Italic: *text* or _text_
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
// Strikethrough: ~~text~~
reStrike = regexp.MustCompile(`~~(.+?)~~`)
// Blockquote: > prefix
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
// Horizontal rule
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
// Unordered list markers: -, *, +
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
)
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
func MarkdownToPlainText(text string) string {
result := text
// Code blocks: strip fences, keep code content
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
parts := reCodeBlock.FindStringSubmatch(match)
if len(parts) > 1 {
return strings.TrimSpace(parts[1])
}
return match
})
// Images: remove entirely
result = reImage.ReplaceAllString(result, "")
// Links: keep display text only
result = reLink.ReplaceAllString(result, "$1")
// Table separator rows: remove
result = reTableSep.ReplaceAllString(result, "")
// Table rows: pipe-delimited to space-delimited
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
parts := reTableRow.FindStringSubmatch(match)
if len(parts) > 1 {
cells := strings.Split(parts[1], "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return strings.Join(cells, " ")
}
return match
})
// Headers: remove # prefix
result = reHeader.ReplaceAllString(result, "")
// Bold
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
parts := reBold.FindStringSubmatch(match)
if parts[1] != "" {
return parts[1]
}
return parts[2]
})
// Strikethrough
result = reStrike.ReplaceAllString(result, "$1")
// Blockquote
result = reBlockquote.ReplaceAllString(result, "")
// Horizontal rule -> empty line
result = reHR.ReplaceAllString(result, "")
// Unordered list: replace markers with "• "
result = reUL.ReplaceAllString(result, "${1}• ")
// Inline code: strip backticks (do after code blocks)
result = reInlineCode.ReplaceAllString(result, "$1")
// Clean up excessive blank lines
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
return strings.TrimSpace(result)
}
@@ -1,221 +0,0 @@
package messaging
import (
"context"
"fmt"
"io"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
// reMarkdownImage matches markdown image syntax: ![alt](url)
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
// ExtractImageURLs extracts image URLs from markdown text.
func ExtractImageURLs(text string) []string {
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
var urls []string
for _, m := range matches {
url := strings.TrimSpace(m[1])
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
urls = append(urls, url)
}
}
return urls
}
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
// Check if it's a local file
if _, err := os.Stat(mediaURL); err == nil {
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
}
// Must be a valid HTTP URL to download
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
}
data, contentType, err := downloadFile(ctx, mediaURL)
if err != nil {
return fmt.Errorf("download %s: %w", mediaURL, err)
}
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
}
// SendMediaFromPath reads a local file and sends it as a media message.
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
}
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
if fileName == "" {
fileName = "file"
}
cdnMediaType, itemType := classifyMedia(contentType, source)
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
if err != nil {
return fmt.Errorf("upload to CDN: %w", err)
}
media := &ilink.MediaInfo{
EncryptQueryParam: uploaded.DownloadParam,
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
EncryptType: 1,
}
var item ilink.MessageItem
switch itemType {
case ilink.ItemTypeImage:
item = ilink.MessageItem{
Type: ilink.ItemTypeImage,
ImageItem: &ilink.ImageItem{
Media: media,
MidSize: uploaded.CipherSize,
},
}
case ilink.ItemTypeVideo:
item = ilink.MessageItem{
Type: ilink.ItemTypeVideo,
VideoItem: &ilink.VideoItem{
Media: media,
VideoSize: uploaded.CipherSize,
},
}
default:
item = ilink.MessageItem{
Type: ilink.ItemTypeFile,
FileItem: &ilink.FileItem{
Media: media,
FileName: fileName,
Len: fmt.Sprintf("%d", uploaded.FileSize),
},
}
}
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: NewClientID(),
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{item},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send media message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
return nil
}
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = inferContentType(url)
}
return data, contentType, nil
}
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
ct := strings.ToLower(contentType)
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
}
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
}
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
}
func isImageExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
}
return false
}
func isVideoExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
return true
}
return false
}
func inferContentType(url string) string {
ext := filepath.Ext(stripQuery(url))
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func filenameFromURL(rawURL string) string {
u := stripQuery(rawURL)
name := filepath.Base(u)
if name == "" || name == "." || name == "/" {
return "file"
}
return name
}
func stripQuery(rawURL string) string {
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
return rawURL[:i]
}
return rawURL
}
@@ -1,73 +0,0 @@
package messaging
import "testing"
func TestExtractImageURLs(t *testing.T) {
text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)"
urls := ExtractImageURLs(text)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %d", len(urls))
}
if urls[0] != "https://example.com/a.png" {
t.Errorf("urls[0] = %q", urls[0])
}
if urls[1] != "https://example.com/b.jpg" {
t.Errorf("urls[1] = %q", urls[1])
}
}
func TestExtractImageURLs_NoImages(t *testing.T) {
urls := ExtractImageURLs("just plain text")
if len(urls) != 0 {
t.Errorf("expected 0 urls, got %d", len(urls))
}
}
func TestExtractImageURLs_RelativeURL(t *testing.T) {
text := "![img](./local.png)"
urls := ExtractImageURLs(text)
if len(urls) != 0 {
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
}
}
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/photo.png", "photo.png"},
{"https://example.com/path/to/report.pdf", "report.pdf"},
{"https://example.com/file", "file"},
}
for _, tt := range tests {
got := filenameFromURL(tt.url)
if got != tt.want {
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFilenameFromURL_WithQuery(t *testing.T) {
got := filenameFromURL("https://example.com/photo.png?token=abc")
if got != "photo.png" {
t.Errorf("got %q, want %q", got, "photo.png")
}
}
func TestStripQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/a?b=c", "https://example.com/a"},
{"https://example.com/a", "https://example.com/a"},
{"https://example.com/?x=1&y=2", "https://example.com/"},
}
for _, tt := range tests {
got := stripQuery(tt.input)
if got != tt.want {
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -1,86 +0,0 @@
package messaging
import (
"context"
"fmt"
"log"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/google/uuid"
)
// NewClientID generates a new unique client ID for message correlation.
func NewClientID() string {
return uuid.New().String()
}
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
// It first fetches a typing_ticket via getconfig, then sends the typing status.
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
// Get typing ticket
configResp, err := client.GetConfig(ctx, userID, contextToken)
if err != nil {
return fmt.Errorf("get config for typing: %w", err)
}
if configResp.TypingTicket == "" {
return fmt.Errorf("no typing_ticket returned from getconfig")
}
// Send typing
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
return fmt.Errorf("send typing: %w", err)
}
log.Printf("[sender] sent typing indicator to %s", userID)
return nil
}
// SendTextReply sends a text reply to a user through the iLink API.
// If clientID is empty, a new one is generated.
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
if clientID == "" {
clientID = NewClientID()
}
// Convert markdown to plain text for WeChat display
plainText := MarkdownToPlainText(text)
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: clientID,
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{
{
Type: ilink.ItemTypeText,
TextItem: &ilink.TextItem{
Text: plainText,
},
},
},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_DEEPSEEK_API_KEY: string
readonly VITE_SCHEDULED_REPORT_DEBUG: string
}
interface ImportMeta {
+167 -19
View File
@@ -329,6 +329,32 @@ body {
color: var(--accent);
}
.timeline-month small { color: inherit; }
.timeline-month-entry { display: grid; gap: 2px; }
.timeline-days {
display: grid;
gap: 1px;
margin: 0 0 4px 11px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.timeline-days[hidden] { display: none; }
.timeline-day {
width: 100%;
display: flex;
justify-content: space-between;
gap: 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
padding: 5px 7px;
cursor: pointer;
font: inherit;
font-size: 11px;
text-align: left;
}
.timeline-day:hover, .timeline-day.active { background: var(--accent-soft); color: var(--accent); }
.timeline-day small { color: inherit; }
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
.lazy-hint {
width: min(100%, 820px);
@@ -787,6 +813,23 @@ body {
}
.timeline-months { display: flex; gap: 6px; }
.timeline-months[hidden] { display: none; }
.timeline-month-entry { display: grid; gap: 2px; }
.timeline-days {
display: flex;
gap: 4px;
margin: 0;
padding: 0;
border-left: 0;
}
.timeline-days[hidden] { display: none; }
.timeline-day {
width: auto;
flex: 0 0 auto;
border-bottom: 2px solid transparent;
padding: 5px 6px;
white-space: nowrap;
}
.timeline-day:hover, .timeline-day.active { border-bottom-color: var(--accent); }
.timeline-month {
flex: 0 0 auto;
width: auto;
@@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => `
let scrollLoadSuppressed = false
let activeMonthUpdatePending = false
let expandedTimelineYear = ''
let expandedTimelineMonth = ''
let selectedTimelineMonth = ''
let selectedTimelineDate = ''
const tabPositions = new Map()
let lastScrollTop = 0
let zoom = 1
@@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => `
const date = new Date(timestamp * 1000)
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
}
const dateKey = (message) => {
const timestamp = Number(message.createTime || 0)
if (!timestamp) return 'unknown'
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) return 'unknown'
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
}
const kindOf = (message) => {
const data = message.contentData || {}
const shareType = String(data.typeVal || '')
@@ -1119,13 +1172,37 @@ const renderExportScript = (name: string): string => `
}
const renderPaymentContent = (data, kind) => {
const isTransfer = kind === 'transfer'
const pay = (isTransfer ? data.transfer : data.pay) || {}
const amount = pay.amountText
const memo = pay.payMemo
const statusText = isTransfer ? pay.transferStatusText : pay.redPacketStatusText
const meta = [amount, memo ? '备注:' + memo : '', statusText]
.filter(Boolean)
.join(' · ')
const title = isTransfer
? (data.title || '微信转账')
: (pay.sendTitle || pay.receiveTitle || data.title || '微信红包')
const blurb =
data.description ||
data.des ||
(isTransfer ? '转账消息' : (pay.sceneText || '恭喜发财,大吉大利'))
// 状态/金额/备注始终单独露出,避免被消息原文 des 盖住。
const description = meta
? (blurb && blurb !== meta ? blurb + ' · ' + meta : meta)
: blurb
const footerBits = isTransfer
? [pay.paySubtype === '3' ? '收款' : pay.paySubtype === '1' || pay.paySubtype === '4' ? '转账' : '',
pay.transferId ? '单号 ' + pay.transferId : '']
: [pay.hbType ? '类型 ' + pay.hbType : '', pay.sendId ? 'sendid ' + pay.sendId : '']
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' +
'<div class="structured-description">' +
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) +
'</div></div>'
'<div class="structured-title">' + displayText(title) + '</div>' +
'<div class="structured-description">' + displayText(description) + '</div>' +
(footerBits.filter(Boolean).length
? '<div class="structured-footer">' + displayText(footerBits.filter(Boolean).join(' · ')) + '</div>'
: '') +
'</div>'
}
const renderVoipContent = (data) => {
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
@@ -1317,6 +1394,15 @@ const renderExportScript = (name: string): string => `
if (months) months.hidden = !expanded
})
}
const setExpandedTimelineMonth = (month) => {
expandedTimelineMonth = month || ''
timeline.querySelectorAll('.timeline-month').forEach((button) => {
const expanded = button.dataset.month === expandedTimelineMonth
button.setAttribute('aria-expanded', String(expanded))
const days = button.nextElementSibling
if (days) days.hidden = !expanded
})
}
const renderTimeline = () => {
if (filtered.length === 0) {
expandedTimelineYear = ''
@@ -1327,18 +1413,36 @@ const renderExportScript = (name: string): string => `
for (const message of filtered) {
const key = monthKey(message)
if (key === 'unknown') continue
groups.set(key, (groups.get(key) || 0) + 1)
const date = dateKey(message)
const group = groups.get(key) || { total: 0, days: new Map() }
group.total += 1
if (date !== 'unknown') group.days.set(date, (group.days.get(date) || 0) + 1)
groups.set(key, group)
}
const yearGroups = new Map()
for (const [key, total] of groups) {
for (const [key, group] of groups) {
const parts = key.split('-')
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total })
yearGroups.get(parts[0]).push({
key,
month: Number(parts[1]),
total: group.total,
days: Array.from(group.days.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([date, total]) => ({ date, day: Number(date.slice(-2)), total }))
})
}
const years = Array.from(yearGroups.keys())
const years = Array.from(yearGroups.keys()).sort((left, right) => left.localeCompare(right))
if (!years.includes(expandedTimelineYear)) {
expandedTimelineYear = years[years.length - 1] || ''
}
for (const entries of yearGroups.values()) {
entries.sort((left, right) => left.key.localeCompare(right.key))
}
const allMonthKeys = Array.from(groups.keys()).sort((left, right) => left.localeCompare(right))
if (!allMonthKeys.includes(expandedTimelineMonth)) {
expandedTimelineMonth = allMonthKeys[allMonthKeys.length - 1] || ''
}
let html = ''
for (const year of years) {
const expanded = year === expandedTimelineYear
@@ -1348,10 +1452,18 @@ const renderExportScript = (name: string): string => `
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
esc(year) + ' 年</button>' +
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
yearGroups.get(year).map((entry) =>
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
).join('') + '</div></section>'
yearGroups.get(year).map((entry) => {
const monthExpanded = entry.key === expandedTimelineMonth
return '<div class="timeline-month-entry">' +
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) +
'" aria-expanded="' + String(monthExpanded) + '">' +
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>' +
'<div class="timeline-days" data-days-for-month="' + esc(entry.key) + '"' +
(monthExpanded ? '' : ' hidden') + '>' + entry.days.map((day) =>
'<button class="timeline-day" type="button" data-date="' + esc(day.date) + '">' +
'<span>' + esc(day.date.slice(5)) + '</span><small>' + day.total + '</small></button>'
).join('') + '</div></div>'
}).join('') + '</div></section>'
}
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
}
@@ -1377,16 +1489,27 @@ const renderExportScript = (name: string): string => `
const activeMessage = atBottom
? visible[visible.length - 1] || messages[messages.length - 1]
: anchoredMessage || visible[0] || messages[0]
const key = activeMessage && activeMessage.dataset.month
const key = selectedTimelineMonth || (selectedTimelineDate
? selectedTimelineDate.slice(0, 7)
: '') ||
(activeMessage && activeMessage.dataset.month)
const activeIndex = activeMessage ? Number(activeMessage.dataset.index) : -1
const activeDate =
selectedTimelineDate ||
(activeIndex >= 0 && filtered[activeIndex] ? dateKey(filtered[activeIndex]) : '')
let activeButton
timeline.querySelectorAll('.timeline-month').forEach((button) => {
const active = button.dataset.month === key
button.classList.toggle('active', active)
if (active) activeButton = button
})
timeline.querySelectorAll('.timeline-day').forEach((button) => {
button.classList.toggle('active', button.dataset.date === activeDate)
})
if (!activeButton) return
const activeYear = key.split('-')[0]
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key)
const timelineBounds = timeline.getBoundingClientRect()
const buttonBounds = activeButton.getBoundingClientRect()
if (buttonBounds.top < timelineBounds.top) {
@@ -1535,6 +1658,8 @@ const renderExportScript = (name: string): string => `
)
}
const applyFilters = (restorePosition = false) => {
selectedTimelineMonth = ''
selectedTimelineDate = ''
filtered = matchingMessages()
renderTimeline()
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
@@ -1572,18 +1697,32 @@ const renderExportScript = (name: string): string => `
target.classList.add('located')
window.setTimeout(() => target.classList.remove('located'), 1600)
}
const jumpToMonth = (key) => {
const index = filtered.findIndex((message) => monthKey(message) === key)
const selectTimelineMonth = (key) => {
if (!filtered.some((message) => monthKey(message) === key)) return
selectedTimelineMonth = key
selectedTimelineDate = ''
setExpandedTimelineYear(key.split('-')[0])
setExpandedTimelineMonth(key)
timeline.querySelectorAll('.timeline-month').forEach((button) => {
button.classList.toggle('active', button.dataset.month === key)
})
}
const jumpToDate = (key) => {
const index = filtered.findIndex((message) => dateKey(message) === key)
if (index < 0) return
selectedTimelineMonth = ''
selectedTimelineDate = key
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
renderWindow()
const target = list.querySelector('.message[data-index="' + index + '"]')
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
setExpandedTimelineYear(key.split('-')[0])
timeline.querySelectorAll('.timeline-month').forEach((button) => {
button.classList.toggle('active', button.dataset.month === key)
const month = key.slice(0, 7)
setExpandedTimelineYear(month.slice(0, 4))
setExpandedTimelineMonth(month)
timeline.querySelectorAll('.timeline-day').forEach((button) => {
button.classList.toggle('active', button.dataset.date === key)
})
}
const slideWindow = (direction) => {
@@ -1620,6 +1759,10 @@ const renderExportScript = (name: string): string => `
const nearTop = currentTop < 180
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
lastScrollTop = currentTop
if (!scrollLoadSuppressed) {
selectedTimelineMonth = ''
selectedTimelineDate = ''
}
scheduleActiveMonthUpdate()
if (scrollLoadSuppressed) return
if (movingUp && nearTop) scheduleWindowSlide(-1)
@@ -1676,7 +1819,12 @@ const renderExportScript = (name: string): string => `
return
}
const button = event.target.closest('[data-month]')
if (button) jumpToMonth(button.dataset.month)
if (button) {
selectTimelineMonth(button.dataset.month)
return
}
const dayButton = event.target.closest('[data-date]')
if (dayButton) jumpToDate(dayButton.dataset.date)
})
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
+176 -83
View File
@@ -25,11 +25,13 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import { imageFileQuality } from '../shared/image-quality'
import { resolveMemberName } from '../shared/member-names'
import { FavoritesService } from './favorites-service'
import { filesystemSafeName } from '../shared/contact-name'
const jobs = new Set<string>()
const activeArchives = new Map<string, Archiver>()
const safeFilePart = (value: string): string =>
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
const safeFilePart = (value: string, fallback = '聊天档案'): string =>
filesystemSafeName(value, fallback)
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
try {
await fs.chmod(destination, 0o644)
@@ -643,6 +645,7 @@ const kindOf = (message: Message): ExportMessageKind => {
return 'text'
}
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
const csvBom = '\uFEFF'
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
if (format === 'html') return renderExportPage(name)
@@ -650,7 +653,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
if (format === 'markdown')
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
return [
return csvBom + [
'时间,发送者,类型,内容,媒体路径,媒体状态',
...messages.map((m) =>
[
@@ -664,7 +667,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
.map(csv)
.join(',')
)
].join('\n')
].join('\r\n')
}
interface SingleExportOptions {
@@ -681,6 +684,8 @@ interface AllExportManifestEntry {
type: ExportTarget['type']
folder: string
messageCount: number
status: 'completed' | 'failed'
error?: string
}
const writeAllExportManifest = async (
@@ -849,6 +854,28 @@ async function runSingleExport(
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
})
}
if (request.includeFavorites) {
const wcdb = chat.getChatDb()?.getWcdb4Client()
if (wcdb) {
try {
const favoriteMessages = await new FavoritesService(wcdb).listExportMessages(500)
for (const [messageOrder, message] of favoriteMessages.entries()) {
if (!request.kinds.includes(kindOf(message))) continue
messageEntries.push({
message: {
...message,
exportConversationId: 'favorites',
exportConversationName: '收藏'
},
targetOrder: targets.length,
messageOrder
})
}
} catch (error) {
console.warn('[export] favorites merge skipped:', error)
}
}
}
const messages = messageEntries
.sort((left, right) => {
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
@@ -941,10 +968,7 @@ async function runSingleExport(
request.format === 'html'
? options.outputFolderName || safeFilePart(request.outputName)
: `${safeFilePart(request.outputName)}_${exportStamp()}`
const root =
options.outputRoot ||
request.outputDirectory ||
(await resolveDefaultExportRoot(outputFolder))
const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
await fs.mkdir(root, { recursive: true })
const outputDir = join(root, outputFolder)
const outputPath =
@@ -1089,7 +1113,10 @@ async function runSingleExport(
}
const voiceService =
request.includeMedia && chat.getChatDb()
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
? new VoiceService(
chat.getChatDb()!.getWcdb4Client(),
chat.getChatDb()!.getWcdb4Client().getAccountRoot()
)
: null
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
@@ -1236,7 +1263,21 @@ async function runSingleExport(
markResourceExists(voiceUrl)
}
message.voiceDataUrl = voiceUrl
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
// 读取 WAV 头中的采样率,避免按错误采样率计算时长(Silk 解码为 16kHz)
const wavSampleRate =
audioBuffer.length >= 44 ? audioBuffer.readUInt32LE(24) : 16000
const wavChannels =
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
const pcmBytes = Math.max(0, audioBuffer.length - 44)
// 口径统一:消息解析阶段已从 <voicemsg voicelength> 拿到微信的原始秒数(带小数),
// 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算——
// 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。
if (message.voiceDuration == null) {
message.voiceDuration = Math.max(
1,
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
)
}
} catch (error) {
keepMediaError(
request,
@@ -1478,10 +1519,13 @@ async function runSingleExport(
} else if (message.contentData.type === 'sticker' && stickerService) {
const stickerSource = message.contentData.url || message.contentData.thumbUrl
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
const decoded = result.data ? decodeDataUrl(result.data) : null
const stickerExtension = decoded ? detectAssetExtension(decoded.buffer) : null
if (decoded && stickerExtension) {
const name = `sticker_${bufferHashPart(decoded.buffer)}.${stickerExtension}`
const decoded = result.data
? decodeDataUrl(result.data)
: stickerSource
? await readAvatarAsset(stickerSource)
: null
if (decoded) {
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
const mediaUrl = `media/${name}`
if (!(await resourceExists(mediaUrl))) {
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
@@ -1626,6 +1670,8 @@ async function runAllExport(
let outputDir = ''
const manifest: AllExportManifestEntry[] = []
let totalMessages = 0
let failedCount = 0
const failureMessages: string[] = []
try {
const targets = [...(request.targets || [])].sort((left, right) =>
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
@@ -1658,80 +1704,121 @@ async function runAllExport(
const categoryName = target.type === 'group' ? '群聊' : '联系人'
const categoryDir = join(outputDir, categoryName)
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
await fs.mkdir(categoryDir, { recursive: true })
const conversationOutputRoot =
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
const basePercent = Math.floor((targetIndex / targets.length) * 100)
send({
jobId: request.jobId,
phase: 'reading',
processed: 0,
percent: basePercent,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
const folder = `${categoryName}/${folderName}`
let result: ExportResult
try {
await fs.mkdir(categoryDir, { recursive: true })
const conversationOutputRoot =
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
const basePercent = Math.floor((targetIndex / targets.length) * 100)
send({
jobId: request.jobId,
phase: 'reading',
processed: 0,
percent: basePercent,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
const result = await runSingleExport(
{
...request,
targets: [target],
outputName: target.name,
zip: false
},
win,
voiceRecognition,
{
outputRoot: conversationOutputRoot,
outputFolderName: request.format === 'html' ? folderName : undefined,
manageJob: false,
selfInfo,
sendProgress: (childProgress) => {
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
const percent = Math.min(
99,
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
)
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
const now = Date.now()
const progressKey = `${targetIndex}:${phase}:${percent}`
const terminal = phase === 'failed' || phase === 'cancelled'
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
lastProgressKey = progressKey
lastProgressAt = now
send({
...childProgress,
jobId: request.jobId,
phase,
percent,
outputPath: undefined,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
result = await runSingleExport(
{
...request,
targets: [target],
outputName: target.name,
zip: false
},
win,
voiceRecognition,
{
outputRoot: conversationOutputRoot,
outputFolderName: request.format === 'html' ? folderName : undefined,
manageJob: false,
selfInfo,
sendProgress: (childProgress) => {
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
const percent = Math.min(
99,
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
)
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
const now = Date.now()
const progressKey = `${targetIndex}:${phase}:${percent}`
const terminal = phase === 'failed' || phase === 'cancelled'
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
lastProgressKey = progressKey
lastProgressAt = now
send({
...childProgress,
jobId: request.jobId,
phase,
percent,
outputPath: undefined,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
}
}
}
)
if (!result.success) {
if (result.error === '已取消') throw new Error('已取消')
throw new Error(`${target.name}:${result.error || '导出失败'}`)
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
result = { success: false, error: message }
}
const messageCount = result.messageCount || 0
totalMessages += messageCount
manifest.push({
id: target.userMd5,
name: target.name,
type: target.type,
folder: `${categoryName}/${folderName}`,
messageCount
})
if (!result.success) {
if (result.error === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
const error = `${target.name}:${result.error || '导出失败'}`
failedCount += 1
failureMessages.push(error)
manifest.push({
id: target.userMd5,
name: target.name,
type: target.type,
folder,
messageCount: 0,
status: 'failed',
error: result.error || '导出失败'
})
const failedPercent = Math.min(
99,
Math.floor(((targetIndex + 1) / targets.length) * 100)
)
send({
jobId: request.jobId,
phase: 'failed',
processed: totalMessages,
total: totalMessages,
percent: failedPercent,
error,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
} else {
const messageCount = result.messageCount || 0
totalMessages += messageCount
manifest.push({
id: target.userMd5,
name: target.name,
type: target.type,
folder,
messageCount,
status: 'completed'
})
}
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
}
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
const finalStatus = failedCount ? 'failed' : 'completed'
const finalError = failedCount
? `全部导出完成:成功 ${targets.length - failedCount} 个,失败 ${failedCount} 个。${failureMessages.join(';')}`
: undefined
await writeAllExportManifest(outputDir, manifest, totalMessages, finalStatus, finalError)
let completedPath = outputDir
if (request.zip) {
@@ -1750,17 +1837,23 @@ async function runAllExport(
send({
jobId: request.jobId,
phase: 'completed',
phase: failedCount ? 'failed' : 'completed',
processed: totalMessages,
total: totalMessages,
percent: 100,
error: finalError,
outputPath: completedPath,
currentTargetIndex: targets.length,
currentTargetCount: targets.length,
currentTargetName: targets.at(-1)?.name,
currentTargetType: targets.at(-1)?.type
})
return { success: true, outputPath: completedPath, messageCount: totalMessages }
return {
success: failedCount === 0,
outputPath: completedPath,
messageCount: totalMessages,
...(finalError ? { error: finalError } : {})
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const cancelled = !jobs.has(request.jobId) || message === '已取消'
+71
View File
@@ -0,0 +1,71 @@
import type { Message, ParsedContent } from '../shared/types'
import { favoriteRowToContent } from '../shared/favorites'
import type { Wcdb4Client } from './wcdb4-client'
/** 收藏只读服务:favorite.db → 可读卡片 / 导出消息。 */
export class FavoritesService {
constructor(private readonly wcdb4Client: Wcdb4Client) {}
async listContents(limit = 200): Promise<ParsedContent[]> {
const rows = await this.wcdb4Client.listFavoriteItems(limit)
return rows.map((row) => favoriteRowToContent(row))
}
/** 收藏转成导出/搜索可用的 Message 列表(合成会话「收藏」)。 */
async listExportMessages(limit = 200): Promise<Message[]> {
const rows = await this.wcdb4Client.listFavoriteItems(limit)
return rows.map((row, index) => {
const contentData = favoriteRowToContent(row)
const createTime = Number(row.update_time) || 0
return {
id: `fav-${row.local_id ?? index}`,
from: 'favorite',
type: '收藏',
datetime: createTime ? new Date(createTime * 1000).toISOString() : '',
content: textOf(contentData),
isSender: false,
createTime,
name: '收藏',
contentData
} as Message
})
}
/** 只读关键词搜索收藏文本(title/desc/正文)。 */
async search(query: string, limit = 50): Promise<ParsedContent[]> {
const needle = String(query || '').trim().toLowerCase()
if (!needle) return []
const items = await this.listContents(500)
return items.filter((item) => textOf(item).toLowerCase().includes(needle)).slice(0, limit)
}
/** Local Query API 适配:只要文本与时间戳。 */
async searchHits(
query: string,
limit = 20
): Promise<Array<{ text: string; timestamp?: number }>> {
const rows = await this.wcdb4Client.listFavoriteItems(500)
const needle = String(query || '').trim().toLowerCase()
if (!needle) return []
return rows
.map((row) => {
const content = favoriteRowToContent(row)
return {
text: textOf(content),
timestamp: Number(row.update_time) || undefined
}
})
.filter((hit) => hit.text.toLowerCase().includes(needle))
.slice(0, limit)
}
}
function textOf(content: ParsedContent): string {
if (content.type === 'text') return content.content
if (content.type === 'system') return content.content
if (content.type === 'share') return [content.title, content.des].filter(Boolean).join(' · ')
if (content.type === 'location') return content.poiname || content.label || '[位置]'
if (content.type === 'miniProgram') return content.title || '[小程序]'
if (content.type === 'forwardBundle') return content.title || '[聊天记录]'
return '[收藏]'
}
+396 -98
View File
@@ -1,7 +1,10 @@
import { app, BrowserWindow } from 'electron'
import crypto from 'node:crypto'
import { appendFileSync } from 'node:fs'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { fileURLToPath } from 'node:url'
import {
GroupReportExportRequest,
GroupReportExportResult,
@@ -10,11 +13,16 @@ import {
GroupReportRenderSnapshotExportRequest,
ReportHeat,
ReportSectionMeta,
buildReportAvatarAliasIndex,
mergeReportAvatars,
selectHeroParticipantNames
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
import { imageInsightService } from './services/image-insight-service'
import { getReportTemplate } from '../shared/report-templates'
import type { ReportTemplateRef } from '../shared/report-template-package'
import { injectReportTemplateFragmentContract } from '../shared/report-template-fragment-contract'
import { reportTemplateService, validateReportTemplateHtml } from './report-template-service'
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
v1: 'mobile_daily_report_v1.html',
@@ -33,6 +41,40 @@ const templatePath = (templateId?: string): string => {
return found
}
const resolveTemplateSelection = async (templateId?: string, templateRef?: ReportTemplateRef) => {
if (!templateRef) {
const definition = getReportTemplate(templateId)
return {
definition,
entryPath: templatePath(templateId),
captureMaxHeight: 20000,
source: 'builtin' as const
}
}
const installed = await reportTemplateService.resolve(templateRef)
return {
definition: {
id: installed.id,
order: 999,
platform: 'default' as const,
label: installed.name,
name: installed.name,
tagline: `${installed.author} · ${installed.version}`,
fileLabel: installed.name,
cssClass: `template-external-${installed.id.replace(/[^a-z0-9-]/gi, '-')}`,
resourceFile: installed.entryPath,
captureWidth: installed.capture.width,
maxCaptureWidth: installed.capture.maxWidth
},
entryPath: installed.entryPath,
captureMaxHeight: installed.capture.maxHeight,
source: 'installed' as const
}
}
const resolveTemplate = async (request: GroupReportExportRequest) =>
resolveTemplateSelection(request.templateId, request.templateRef)
const escapeHtml = (value: unknown): string =>
String(value ?? '')
.replace(/&/g, '&amp;')
@@ -53,55 +95,161 @@ const hashName = (name: string): number => {
return hash
}
const fallbackAvatar = (name: string): string => {
type RenderedAvatar = { source: string; fallback: boolean }
const fallbackAvatar = (name: string): RenderedAvatar => {
const hue = hashName(name) % 360
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
const imageMimeType = (contentType: string | null, source: string): string => {
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
const extension = path.extname(source).toLowerCase()
if (extension === '.png') return 'image/png'
if (extension === '.webp') return 'image/webp'
if (extension === '.gif') return 'image/gif'
return 'image/jpeg'
}
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
try {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, {
headers: {
'User-Agent': 'Mozilla/5.0 TraceMemo',
Referer: 'https://weixin.qq.com/'
},
signal: AbortSignal.timeout(8000)
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const mime = imageMimeType(response.headers.get('content-type'), source)
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
}
const localPath = source.startsWith('file://') ? new URL(source) : source
const buffer = await fs.readFile(localPath)
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
} catch (error) {
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
return fallbackAvatar(name)
return {
source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
fallback: true
}
}
/**
* 只认真实图片字节。
*
* `content-type` 与 URL 扩展名都**不可信**:微信 CDN 在限流 / 反盗链时会返回 200 + HTML 正文。
* 旧实现按扩展名猜 mime 并默认 `image/jpeg`,会把 HTML 内联成"解码失败的 data URL" ——
* 在报告里表现为**空白头像**(比首字占位更糟:用户看不到任何东西,也不知道为什么)。
*/
const IMAGE_MAGIC: Array<{ mime: string; bytes: number[] }> = [
{ mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
{ mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
{ mime: 'image/bmp', bytes: [0x42, 0x4d] }
]
export const detectImageMime = (bytes: Buffer): string | undefined => {
for (const signature of IMAGE_MAGIC) {
if (
bytes.length >= signature.bytes.length &&
signature.bytes.every((byte, index) => bytes[index] === byte)
) {
return signature.mime
}
}
if (
bytes.length >= 12 &&
bytes.toString('ascii', 0, 4) === 'RIFF' &&
bytes.toString('ascii', 8, 12) === 'WEBP'
) {
return 'image/webp'
}
const head = bytes.subarray(0, 64).toString('utf8').trimStart()
if (head.startsWith('<svg')) return 'image/svg+xml'
if (head.startsWith('<?xml') && head.includes('<svg')) return 'image/svg+xml'
return undefined
}
const AVATAR_FETCH_TIMEOUT_MS = 8000
/** 首次 + 一次重试:单次瞬时失败(限流 / 连接重置 / 超时)不该让一个人永久退回首字。 */
const AVATAR_FETCH_ATTEMPTS = 2
/** 同一 origin 的并发上限。几十个头像同时打一个 CDN 会显著抬高被限流的概率。 */
const AVATAR_FETCH_CONCURRENCY = 6
/** 进程内头像缓存条目上限(老报告重渲染 / 连续生成同一群时不必重复下载)。 */
const AVATAR_CACHE_LIMIT = 256
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const avatarEmbedCache = new Map<string, RenderedAvatar>()
const rememberAvatarEmbed = (source: string, rendered: RenderedAvatar): void => {
if (rendered.fallback) return
avatarEmbedCache.set(source, rendered)
while (avatarEmbedCache.size > AVATAR_CACHE_LIMIT) {
const oldest = avatarEmbedCache.keys().next().value
if (oldest === undefined) break
avatarEmbedCache.delete(oldest)
}
}
/** 有界并发:把 N 个任务压到 limit 个同时在飞,结果顺序与输入一致。 */
export const mapWithConcurrency = async <T, R>(
items: readonly T[],
limit: number,
task: (item: T) => Promise<R>
): Promise<R[]> => {
const results: R[] = new Array(items.length)
let cursor = 0
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
for (;;) {
const index = cursor
cursor += 1
if (index >= items.length) return
results[index] = await task(items[index])
}
})
await Promise.all(workers)
return results
}
const readAvatarSource = async (source: string): Promise<RenderedAvatar> => {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, {
headers: {
'User-Agent': 'Mozilla/5.0 TraceMemo',
Referer: 'https://weixin.qq.com/'
},
signal: AbortSignal.timeout(AVATAR_FETCH_TIMEOUT_MS)
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const bytes = Buffer.from(await response.arrayBuffer())
const mime = detectImageMime(bytes)
if (!mime) {
throw new Error(
`not an image (content-type=${response.headers.get('content-type') || 'unknown'}, ${bytes.length} bytes)`
)
}
return { source: `data:${mime};base64,${bytes.toString('base64')}`, fallback: false }
}
const localPath = source.startsWith('file://') ? new URL(source) : source
const bytes = await fs.readFile(localPath)
const mime = detectImageMime(bytes)
if (!mime) throw new Error(`not an image (${bytes.length} bytes)`)
return { source: `data:${mime};base64,${bytes.toString('base64')}`, fallback: false }
}
export const embedAvatar = async (
source: string | undefined,
name: string
): Promise<RenderedAvatar> => {
if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source))
return { source, fallback: false }
const cached = avatarEmbedCache.get(source)
if (cached) return cached
let lastError: unknown
for (let attempt = 1; attempt <= AVATAR_FETCH_ATTEMPTS; attempt += 1) {
try {
const embedded = await readAvatarSource(source)
rememberAvatarEmbed(source, embedded)
return embedded
} catch (error) {
lastError = error
if (attempt < AVATAR_FETCH_ATTEMPTS) await sleep(150 * attempt)
}
}
console.warn(`[GroupReport] avatar fallback for ${name}:`, lastError)
return fallbackAvatar(name)
}
/**
* 从群成员快照反推真头像,填进 metadata.avatars。
* - 没传 talker → 跳过(向后兼容)
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
* - 同名取首条(P2 风险:群里两人同名)
*
* **必须按多个别名建索引**:报告里的显示名取决于 `memberNameMode`
* (默认 `groupNickname` = 群昵称),而快照的 `nickname` 字段是
* `wechatNickname || groupNickname || username`(见 `normalizeGroupMembers`)。
* 一个成员同时有微信昵称与群昵称且两者不同时,只按 `nickname` 建索引就会**全部对不上**,
* 于是头像 enrichment 静默失效 —— 这正是原先只用单一索引键时的问题。
*/
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
if (!metadata.talker) return
@@ -120,22 +268,16 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
return
}
const index = new Map<string, string>()
for (const member of snapshot.members) {
if (member.nickname && member.avatar && !index.has(member.nickname)) {
index.set(member.nickname, member.avatar)
}
}
// 每个成员的所有可用显示名都指向同一个头像 URL;先到先得,避免同名互相覆盖。
// 只按 `member.nickname` 建索引会在"微信昵称 ≠ 群昵称"时全部对不上 —— 见 shared 里的注释。
const index = buildReportAvatarAliasIndex(snapshot.members)
metadata.avatars = metadata.avatars ?? {}
for (const [name, url] of index) {
if (metadata.avatars[name]) continue
metadata.avatars[name] = url
}
const filled = mergeReportAvatars(metadata.avatars, index)
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)`
`enriched ${filled}/${index.size} member avatar aliases from snapshot (${snapshot.memberCount} members)`
)
}
@@ -148,6 +290,58 @@ const heatClass = (heat: ReportHeat): string => {
const replacePlaceholder = (html: string, key: string, value: string): string =>
html.replaceAll(`{{${key}}}`, value)
const addReportCsp = (html: string, allowFileImages = true): string =>
html.replace(
/<head(\s[^>]*)?>/i,
(head) =>
`${head}<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:${allowFileImages ? ' file:' : ''}; style-src 'unsafe-inline'; font-src 'none'; base-uri 'none'; form-action 'none'">`
)
const inlineTemplateAssets = async (html: string, templateRoot: string): Promise<string> => {
const assetDataUrl = async (relative: string): Promise<string | null> => {
const assetPath = path.resolve(templateRoot, relative)
if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) return null
const extension = path.extname(assetPath).toLowerCase()
const mime =
extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg'
const data = (await fs.readFile(assetPath)).toString('base64')
return `data:${mime};base64,${data}`
}
let result = html
for (const match of html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)) {
const dataUrl = await assetDataUrl(match[2])
if (dataUrl) result = result.replace(match[0], `src="${dataUrl}"`)
}
for (const match of html.matchAll(/\burl\(\s*(['"]?)(assets\/[A-Za-z0-9._/-]+)\1\s*\)/g)) {
const dataUrl = await assetDataUrl(match[2])
if (dataUrl) result = result.replace(match[0], `url("${dataUrl}")`)
}
return result
}
const recordBlockedTemplateRequest = (
details: Electron.OnBeforeRequestListenerDetails,
reason: string
): void => {
// 仅测试入口设置该路径,用于保留运行时拦截证据;生产默认不记录请求明细。
const logPath = process.env.TRACEMEMO_TEMPLATE_SECURITY_LOG
if (!logPath) return
try {
appendFileSync(
logPath,
`${JSON.stringify({
url: details.url,
method: details.method,
resourceType: details.resourceType,
reason
})}\n`,
'utf8'
)
} catch (error) {
console.warn('[GroupReport] failed to record template security block:', error)
}
}
const sectionMeta = (
request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta>
@@ -170,7 +364,8 @@ const overflowNote = (
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request
const template = getReportTemplate(request.templateId)
const resolvedTemplate = await resolveTemplate(request)
const template = resolvedTemplate.definition
const avatarNames = new Set<string>(metadata.heroParticipants)
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
@@ -181,24 +376,42 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map<string, string>()
await Promise.all(
Array.from(avatarNames).map(async (name) => {
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
})
// 有界并发 + 单条重试:几十个头像同时打同一个 CDN 会被限流,瞬时失败会让一个人
// 在整份报告里永久退化成首字占位(实测同一天三次生成:0% / 0% / 32% 失败)。
const renderedAvatars = await mapWithConcurrency(
Array.from(avatarNames),
AVATAR_FETCH_CONCURRENCY,
async (name) => [name, await embedAvatar(metadata.avatars[name], name)] as const
)
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
const avatars = new Map(renderedAvatars)
const fallbackCount = renderedAvatars.filter(([, item]) => item.fallback).length
if (fallbackCount > 0) {
// 只记数量,不记人名:报告本身已经有名字,这里只需要一个可诊断的信号。
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`avatar fallback ${fallbackCount}/${renderedAvatars.length}: 未取到真实头像,已用首字占位`
)
}
const avatar = (name: string): RenderedAvatar => avatars.get(name) || fallbackAvatar(name)
const renderAvatar = (
name: string,
role: 'hero' | 'message' | 'participant' | 'ranking',
legacyClass = '',
alt = ''
): string => {
const rendered = avatar(name)
const fallbackClass = rendered.fallback ? ' tm-avatar--fallback' : ''
return `<img class="${legacyClass ? `${legacyClass} ` : ''}tm-avatar tm-avatar--${role}${fallbackClass}" src="${escapeHtml(rendered.source)}" alt="${escapeHtml(alt)}">`
}
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
const heroAvatars = heroNames
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
.join('')
const heroAvatars = heroNames.map((name) => renderAvatar(name, 'hero', '', name)).join('')
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
const topicCards = report.topics
.map(
(topic) => `<div class="card topic-card">
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
(topic) => `<div class="card topic-card tm-fragment tm-topic-card">
<div class="topic-title-row tm-topic-card__title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
<p>${escapeHtml(topic.summary)}</p>
${
@@ -237,15 +450,15 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
}
}
if (!imageUrl) return ''
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
return `<div class="topic-inline-image"><img src="${escapeHtml(imageUrl)}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
})()
: ''
}
<div class="participants">${topic.participants
<div class="participants tm-topic-card__participants">${topic.participants
.slice(0, 5)
.map(
(name) =>
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
`<span class="person-chip tm-participant">${renderAvatar(name, 'participant')}<b class="tm-participant__name">${escapeHtml(name)}</b></span>`
)
.join('')}</div>
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
@@ -262,10 +475,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const importantMessages = report.importantMessages
.map(
(message) => `<div class="important-card">
<img class="avatar" src="${avatar(message.sender)}" alt="">
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
(message) => `<div class="important-card tm-fragment tm-message tm-message--important">
${renderAvatar(message.sender, 'message', 'avatar')}
<div class="important-body tm-message__body"><div class="important-meta tm-message__meta"><b class="tm-message__author">${escapeHtml(message.sender)}</b><span class="tm-message__time">${escapeHtml(message.time)}</span></div>
<div class="important-text tm-message__text">${escapeHtml(message.content)}</div><div class="important-note tm-message__note">${escapeHtml(message.note)}</div></div>
</div>`
)
.join('')
@@ -273,12 +486,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const quoteBlocks = report.quotes
.map(
(quote) =>
`<div class="chat-block">${quote.messages
`<div class="chat-block tm-fragment tm-quote">${quote.messages
.map(
(
message
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
) => `<div class="chat-msg tm-message tm-message--quote">${renderAvatar(message.sender, 'message', 'chat-avatar')}<div class="tm-message__body">
<div class="chat-name tm-message__author">${escapeHtml(message.sender)}</div><div class="chat-bubble tm-message__text">${escapeHtml(message.content)}</div>
</div></div>`
)
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
@@ -287,7 +500,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const todoCards = (report.todos || [])
.map(
(item) => `<div class="action-card todo-card">
(item) => `<div class="action-card todo-card tm-fragment tm-todo-card">
<b>${escapeHtml(item.task)}</b>
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
@@ -297,7 +510,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const unresolvedCards = (report.unresolved || [])
.map(
(item) => `<div class="action-card unresolved-card">
(item) => `<div class="action-card unresolved-card tm-fragment tm-unresolved-card">
<b>${escapeHtml(item.question)}</b>
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
<div class="action-note">${escapeHtml(item.note)}</div>
@@ -324,7 +537,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const reversalCards = (report.reversals || [])
.map(
(item) => `<div class="qa-card">
(item) => `<div class="qa-card tm-fragment tm-reversal-card">
<b>${escapeHtml(item.topic)}</b>
<div>最初:${escapeHtml(item.initialView)}</div>
<div>后来:${escapeHtml(item.finalView)}</div>
@@ -349,7 +562,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
.filter((item) => item.imageUrl) // 只显示加载成功的图
.map(
(item) => `<div class="vision-card">
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
<img class="vision-image" src="${escapeHtml(item.imageUrl)}" alt="AI 识别的图片">
<div class="vision-body">
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
<div class="vision-description">${escapeHtml(item.description)}</div>
@@ -363,7 +576,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const voiceCards = (report.media?.voiceHighlights || [])
.map(
(item) => `<div class="qa-card">
(item) => `<div class="qa-card tm-fragment tm-voice-card">
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
<div>${escapeHtml(item.note)}</div>
</div>`
@@ -372,10 +585,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
.map(
(item, index) => `<div class="rank">
<img src="${avatar(item.sender)}" alt="">
(item, index) => `<div class="rank tm-fragment tm-ranking-item">
${renderAvatar(item.sender, 'ranking')}
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
<span>${item.count} 条 · ${item.durationSec} 秒</span>
<span>${item.count} 条 · ${Math.round(item.durationSec)} 秒</span>
</div>`
)
.join('')
@@ -394,7 +607,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
.slice(0, 5)
.map(
(speaker, index) =>
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
`<div class="rank tm-fragment tm-ranking-item">${renderAvatar(speaker.name, 'ranking')}<b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
)
.join('')
@@ -424,7 +637,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
const qaCards = report.qa
.map(
(item) =>
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
`<div class="qa-card tm-fragment tm-qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
)
.join('')
@@ -441,7 +654,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
unresolvedCount: report.unresolved.length
}
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
if (resolvedTemplate.source === 'installed') {
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
}
const values: Record<string, string> = {
TEMPLATE_CLASS: template.cssClass,
TEMPLATE_LABEL: escapeHtml(template.label),
@@ -547,14 +764,22 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
return html
return addReportCsp(
injectReportTemplateFragmentContract(html),
resolvedTemplate.source === 'builtin'
)
}
const renderReportSnapshotHtml = async (
request: GroupReportRenderSnapshotExportRequest
): Promise<string> => {
const template = getReportTemplate(request.templateId)
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
const template = resolvedTemplate.definition
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
if (resolvedTemplate.source === 'installed') {
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
}
const values = {
...request.snapshot.values,
TEMPLATE_CLASS: template.cssClass,
@@ -565,7 +790,10 @@ const renderReportSnapshotHtml = async (
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
return html.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
return addReportCsp(
html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''),
resolvedTemplate.source === 'builtin'
)
}
export const extractGroupReportRenderSnapshot = async (
@@ -777,23 +1005,68 @@ export const extractGroupReportRenderSnapshot = async (
const captureFullPage = async (
htmlPath: string,
pngPath: string,
templateId?: string
templateId?: string,
templateOverride?: {
captureWidth: number
maxCaptureWidth: number
maxCaptureHeight?: number
}
): Promise<string> => {
const template = getReportTemplate(templateId)
const template = templateOverride || getReportTemplate(templateId)
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
const maxCaptureHeight = templateOverride?.maxCaptureHeight || 20000
console.log(`[GroupReport] capture begin html=${htmlPath}`)
const reportSessionPartition = `report-template-${crypto.randomUUID()}`
const reportWindow = new BrowserWindow({
show: false,
opacity: 0,
skipTaskbar: true,
width: captureWidth,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
partition: reportSessionPartition
}
})
const allowedDocumentPath = path.resolve(htmlPath)
const requestHandler = (
details: Electron.OnBeforeRequestListenerDetails,
callback: (response: { cancel: boolean }) => void
): void => {
if (details.url.startsWith('file:')) {
try {
const requestedPath = path.resolve(fileURLToPath(details.url))
if (requestedPath === allowedDocumentPath) {
callback({ cancel: false })
} else {
recordBlockedTemplateRequest(details, 'file-path-not-allowed')
callback({ cancel: true })
}
} catch {
recordBlockedTemplateRequest(details, 'invalid-file-url')
callback({ cancel: true })
}
return
}
recordBlockedTemplateRequest(details, 'scheme-not-allowed')
callback({ cancel: true })
}
reportWindow.webContents.session.webRequest.onBeforeRequest(
{ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*', 'file://*/*'] },
requestHandler
)
reportWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
reportWindow.webContents.on('will-navigate', (event) => event.preventDefault())
try {
const readyToShow = new Promise<void>((resolve) => reportWindow.once('ready-to-show', resolve))
await reportWindow.loadFile(htmlPath)
await readyToShow
console.log('[GroupReport] capture loaded html')
await reportWindow.webContents.executeJavaScript(`Promise.all([
document.fonts.ready,
@@ -808,17 +1081,29 @@ const captureFullPage = async (
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
})`)) as { width: number; height: number }
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
reportWindow.setContentSize(width, height)
const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height)))
const [currentWidth, currentHeight] = reportWindow.getContentSize()
if (currentWidth !== width || currentHeight !== height) {
const resized = new Promise<void>((resolve) => reportWindow.once('resize', resolve))
reportWindow.setContentSize(width, height)
await resized
}
await reportWindow.webContents.executeJavaScript(
'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'
)
await new Promise((resolve) => setTimeout(resolve, 100))
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
const image = await reportWindow.webContents.capturePage(
{ x: 0, y: 0, width, height },
{ stayHidden: true }
)
const png = image.toPNG()
if (png.length < 1000) throw new Error('生成的日报图片为空')
await fs.writeFile(pngPath, png)
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
return `data:image/png;base64,${png.toString('base64')}`
} finally {
reportWindow.webContents.session.webRequest.onBeforeRequest(null)
reportWindow.destroy()
}
}
@@ -830,10 +1115,14 @@ export const exportGroupReport = async (
// === enrich 在 render 之前:从群成员快照反推真头像 ===
await enrichAvatarsFromGroup(request.metadata)
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
const outputDir =
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const templateLabel =
request.templateId === 'v1'
const resolvedTemplate = await resolveTemplate(request)
const templateLabel = request.templateRef
? resolvedTemplate.definition.fileLabel
: request.templateId === 'v1'
? '经典版'
: request.templateId === 'v2'
? '丰富版'
@@ -846,7 +1135,10 @@ export const exportGroupReport = async (
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
...resolvedTemplate.definition,
maxCaptureHeight: resolvedTemplate.captureMaxHeight
})
const pngEndedAt = new Date()
return {
success: true,
@@ -877,9 +1169,12 @@ export const exportGroupReportSnapshot = async (
request: GroupReportRenderSnapshotExportRequest
): Promise<GroupReportExportResult> => {
try {
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
const outputDir =
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const templateLabel = getReportTemplate(request.templateId).fileLabel
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
const templateLabel = resolvedTemplate.definition.fileLabel
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
@@ -888,7 +1183,10 @@ export const exportGroupReportSnapshot = async (
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
...resolvedTemplate.definition,
maxCaptureHeight: resolvedTemplate.captureMaxHeight
})
const pngEndedAt = new Date()
return {
success: true,
+225 -1
View File
@@ -11,10 +11,22 @@ import {
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { generateAgentGroupReport } from './services/agent-group-report-service'
import { scheduledReportService } from './services/scheduled-report-service'
import { personalWechatCapabilityService } from './services/personal-wechat-capability-service'
import {
ScheduledReportApiError,
ScheduledReportApiService,
type ScheduledReportApiDependencies
} from './services/scheduled-report-api-service'
import type {
ScheduledReportApiCreateRequest,
ScheduledReportApiUpdateRequest
} from '../shared/scheduled-report-api'
import { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log'
import { apiTokenStore } from './api-token-store'
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
import { LocalQueryApiService } from './services/local-query-api-service'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131
@@ -35,6 +47,17 @@ interface RouteContext {
export interface HttpServerOptions {
tokenProvider?: () => string | null
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
scheduledReportService?: ScheduledReportApiDependencies['service']
scheduledReportCapabilityProvider?: ScheduledReportApiDependencies['getCapability']
scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts']
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
scheduledReportPlatform?: NodeJS.Platform
queryApiService?: LocalQueryApiService
}
let configuredQueryApiService: LocalQueryApiService | undefined
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
configuredQueryApiService = service
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
@@ -67,7 +90,7 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
if (!isAllowedCorsOrigin(origin)) return false
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
return true
}
@@ -318,6 +341,11 @@ const routes: Record<string, RouteHandler> = {
if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
}
if (request.templateRef !== undefined) {
return sendError(res, 400, 'HTTP API 暂不支持外部日报模板,请使用内置 templateId', {
code: 'external_template_unsupported'
})
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
},
@@ -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 QUERY_ROUTE_PREFIX = '/api/v1/query/'
function queryStatusCode(status: string): number {
if (status === 'completed') return 200
if (status === 'contact_not_found') return 404
if (status === 'ambiguous_contact') return 409
if (status === 'knowledge_unavailable') return 503
if (status === 'retrieval_incomplete') return 206
return 400
}
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
return async ({ req, res, body }) => {
const pathname = new URL(req.url || '/', 'http://localhost').pathname
if (pathname === '/api/v1/query/capabilities') {
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
return sendJson(res, 200, api.capabilities())
}
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let payload: any
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
try {
const result = pathname === '/api/v1/query/messages'
? await api.messages(payload)
: pathname === '/api/v1/query/search'
? await api.search(payload)
: pathname === '/api/v1/query/message-context'
? await api.context(payload)
: pathname === '/api/v1/query/conversation-overview'
? await api.overview(payload)
: undefined
if (!result) return sendError(res, 404, `端点不存在: ${pathname}`)
return sendJson(res, queryStatusCode(result.status), result)
} catch (error) {
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
}
}
}
function createMediaRoute(
mediaProvider: (messageId: string) => Promise<HttpImageResult>
@@ -424,6 +644,8 @@ export function startHttpServer(
): Promise<HttpServerHandle> {
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
const mediaProvider = options.mediaProvider || readImageMedia
const scheduledReportApi = createScheduledReportApi(options)
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => {
try {
@@ -437,6 +659,8 @@ export function startHttpServer(
}
const handler =
routes[url.pathname] ||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
? createMediaRoute(mediaProvider)
: undefined)
+5 -1
View File
@@ -114,7 +114,11 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa
)
}
function resolveFfmpegExecutable(): string {
/**
* 解析可用的 ffmpeg 可执行文件。除图片解密自身使用外,也供 System OCR 的
* 图片归一化(GIF/BMP/WebP/TIFF → PNG)复用,避免重复一套路径探测逻辑。
*/
export function resolveFfmpegExecutable(): string {
for (const candidate of getFfmpegCandidates()) {
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
if (pathLike) {
+699 -68
View File
File diff suppressed because it is too large Load Diff
+345 -69
View File
@@ -1,11 +1,12 @@
import { app } from 'electron'
import { execFile } from 'child_process'
import { execFile, spawn } from 'child_process'
import fs from 'fs-extra'
import path from 'path'
import { promisify } from 'util'
import { isValidDatabaseKey } from './database-key-store'
import crypto from 'crypto'
import { findResource, getResourceCandidates } from './resource-paths'
import { detectWechatVersion } from './services/connection-diagnostics'
const execFileAsync = promisify(execFile)
@@ -24,12 +25,288 @@ export interface ImageKeyResult {
error?: string
}
export type XkeyHelperMode = 'legacy' | 'wechat-4.1.13'
export function resolveXkeyHelperMode(wechatVersion: string): XkeyHelperMode {
return /^4\.1\.13(?:\.|$)/.test(wechatVersion.trim()) ? 'wechat-4.1.13' : 'legacy'
}
export function buildXkeyHelperArguments(pid: number, timeoutMs: number): string[] {
return [String(pid), String(timeoutMs), '--profile', 'wechat-4.1.13', '--account']
}
export function buildAppleSiliconXkeyInvocation(
wechatVersion: string,
pid: number,
timeoutMs: number
): {
mode: XkeyHelperMode
resourceName: string
args: string[]
waitMs: number
timeoutSeconds: number
execTimeoutMs: number
} {
const mode = resolveXkeyHelperMode(wechatVersion)
const isWechat413 = mode === 'wechat-4.1.13'
const waitMs = Math.max(isWechat413 ? 120_000 : 30_000, timeoutMs)
const timeoutSeconds = Math.ceil(waitMs / 1000) + (isWechat413 ? 10 : 30)
return {
mode,
resourceName: isWechat413 ? 'xkey_helper_4_1_13' : 'xkey_helper',
args: isWechat413 ? buildXkeyHelperArguments(pid, waitMs) : [String(pid), String(waitMs)],
waitMs,
timeoutSeconds,
execTimeoutMs: isWechat413 ? timeoutSeconds * 1000 + 5_000 : waitMs + 20_000
}
}
export function mapXkeyHelperFailure(
rawError: string,
fallbackCode = 'HELPER_RESULT_INVALID'
): DatabaseKeyResult {
const normalizedError = rawError.trim().toLowerCase()
const parsedError = rawError.match(/(?:^|[\s"])(?:\\?"?)ERROR:([^:\s"}]+):?([^"}\r\n]*)/i)
const code = parsedError?.[1]?.toUpperCase()
const detail = parsedError?.[2]?.trim() || ''
if (
code === 'CAPTURE_TIMEOUT' ||
normalizedError.includes('timeout waiting for breakpoint hit') ||
normalizedError.includes('timeout waiting for sink hit') ||
normalizedError.includes('no_breakpoint_hit')
) {
return {
success: false,
code: 'CAPTURE_TIMEOUT',
error:
'已完成管理员授权,但监听期间微信没有触发账号密钥派生。请先停留在微信登录界面,在 TraceMemo 点击“自动获取密钥”,授权后点击微信“登录”;已有登录凭据时通常不需要扫码。'
}
}
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
return {
success: false,
code,
error:
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
}
}
if (code === 'SCAN_FAILED') {
return {
success: false,
code,
error: '内存扫描失败:当前微信版本或运行状态暂未适配。'
}
}
if (normalizedError.includes('permission denied') || code === 'PERMISSION_DENIED') {
return {
success: false,
code: code || 'PERMISSION_DENIED',
error: '管理员授权不足,无法读取微信进程内存。'
}
}
return {
success: false,
code: code || fallbackCode,
error: code
? `密钥工具执行未完成(${code}),请确认微信仍在运行后重试。`
: '密钥工具未返回有效密钥,请确认微信仍在运行后重试。'
}
}
export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
const payloads: Record<string, unknown>[] = []
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
try {
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
} catch {
// Ignore helper progress that is not JSON.
}
}
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
if (isValidDatabaseKey(rawKey)) return { success: true, key: rawKey }
const errorPayload = payloads.find((item) => typeof item.result === 'string')
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
return mapXkeyHelperFailure(rawError)
}
export const SIP_ENABLED_ERROR =
'macOS 系统完整性保护(SIP)已开启,无法自动获取数据库密钥。请先关闭 SIP,或改用手动粘贴。'
export function parseSipEnabled(statusOutput: string): boolean {
const status = statusOutput.match(/status:\s*([a-z]+)/i)?.[1]?.toLowerCase()
if (status) return status === 'enabled'
return statusOutput.toLowerCase().includes('enabled')
}
export class KeyServiceMac {
private getHelperPath(): string {
const helperPath = findResource('xkey_helper')
private getMacKeyRuntimeDir(): string {
return path.join(app.getPath('userData'), 'key-runtime')
}
private async runMacKeyTool(
args: string[],
timeoutMs: number,
onStatus?: (message: string) => void
): Promise<Record<string, unknown>> {
const helperPath = findResource('macos-key-tool/intel_mac_key_helper')
if (!helperPath) {
return {
type: 'result',
success: false,
code: 'KEY_TOOL_UNAVAILABLE',
error: '缺少 Intel Mac 工具'
}
}
return await new Promise<Record<string, unknown>>((resolve) => {
const child = spawn(helperPath, args, {
stdio: ['ignore', 'pipe', 'pipe']
})
let settled = false
let pending = ''
let lastError = ''
let finalPayload: Record<string, unknown> | null = null
let timer: ReturnType<typeof setTimeout> | undefined
const finish = (payload: Record<string, unknown>): void => {
if (settled) return
settled = true
if (timer) clearTimeout(timer)
resolve(payload)
}
const consume = (chunk: Buffer): void => {
pending += chunk.toString()
const lines = pending.split(/\r?\n/)
pending = lines.pop() || ''
for (const line of lines) {
try {
const payload = JSON.parse(line) as Record<string, unknown>
if (payload.type === 'progress' && typeof payload.message === 'string') {
onStatus?.(payload.message)
}
if (payload.type === 'result' || payload.type === 'status') finalPayload = payload
} catch {
// The helper contract is JSONL; ignore interpreter diagnostics.
}
}
}
child.stdout.on('data', consume)
child.stderr.on('data', (chunk: Buffer) => {
lastError = `${lastError}\n${chunk.toString()}`.trim().slice(-1000)
})
child.on('error', (error) =>
finish({
type: 'result',
success: false,
code: 'KEY_TOOL_FAILED',
error: `无法启动 Intel Mac 密钥工具:${error.message}`
})
)
child.on('close', () => {
if (pending) consume(Buffer.from('\n'))
finish(
finalPayload || {
type: 'result',
success: false,
code: 'KEY_TOOL_FAILED',
error: lastError || 'Intel Mac 密钥工具未返回结果'
}
)
})
timer = setTimeout(() => {
try {
child.kill('SIGTERM')
} catch {
// The child may already have exited.
}
finish({
type: 'result',
success: false,
code: 'KEY_TOOL_TIMEOUT',
error: 'Intel Mac 密钥获取超时,请让微信回到未登录界面后重试'
})
}, timeoutMs)
})
}
async getIntelEnvironmentStatus(): Promise<{
sipDisabled: boolean
pythonAvailable: boolean
fridaAvailable: boolean
wechatAdhocSigned: boolean
}> {
const payload = await this.runMacKeyTool(
['status', '--runtime-dir', this.getMacKeyRuntimeDir()],
15_000
)
return {
sipDisabled: payload.sipDisabled === true,
pythonAvailable: payload.pythonAvailable === true,
fridaAvailable: payload.fridaAvailable === true,
wechatAdhocSigned: payload.wechatAdhocSigned === true
}
}
async installIntelKeyRuntime(
onStatus?: (message: string) => void
): Promise<{ success: boolean; error?: string; code?: string }> {
if (process.platform !== 'darwin' || process.arch !== 'x64') {
return { success: false, code: 'UNSUPPORTED_PLATFORM', error: '仅 Intel Mac 需要安装此运行环境' }
}
onStatus?.('正在准备连接环境,请保持网络连接…')
const payload = await this.runMacKeyTool(
['install', this.getMacKeyRuntimeDir()],
5 * 60_000,
onStatus
)
return {
success: payload.success === true,
code: typeof payload.code === 'string' ? payload.code : undefined,
error: typeof payload.error === 'string' ? payload.error : undefined
}
}
private async captureIntelDbKey(
accountRoot: string | undefined,
timeoutMs: number,
onStatus?: (message: string) => void
): Promise<DatabaseKeyResult> {
const selectedRoot = String(accountRoot || '').trim()
if (!selectedRoot) return { success: false, code: 'ACCOUNT_REQUIRED', error: '请先选择微信账号' }
const waitMs = Math.max(30_000, timeoutMs)
const payload = await this.runMacKeyTool(
[
'capture',
'--account-root',
selectedRoot,
'--runtime-dir',
this.getMacKeyRuntimeDir(),
'--diagnostic-log',
path.join(app.getPath('logs'), 'mac-key-diagnostic.log'),
'--timeout-ms',
String(waitMs)
],
waitMs + 15_000,
onStatus
)
const key = typeof payload.key === 'string' ? payload.key.trim().toLowerCase() : ''
if (payload.success === true && isValidDatabaseKey(key)) return { success: true, key }
return {
success: false,
code: typeof payload.code === 'string' ? payload.code : 'KEY_TOOL_FAILED',
error: typeof payload.error === 'string' ? payload.error : '未捕获到微信数据库密钥'
}
}
private getHelperPath(resourceName = 'xkey_helper'): string {
const helperPath = findResource(resourceName)
if (!helperPath) {
throw new Error(
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})`
`找不到 ${resourceName}(已检查:${getResourceCandidates(resourceName).join(';')})`
)
}
return helperPath
@@ -38,7 +315,7 @@ export class KeyServiceMac {
private async isSipEnabled(): Promise<boolean> {
try {
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
return stdout.toLowerCase().includes('enabled')
return parseSipEnabled(stdout)
} catch {
return false
}
@@ -61,77 +338,44 @@ export class KeyServiceMac {
// Try the next process lookup strategy.
}
}
throw new Error('未找到微信主进程,请先启动并登录微信')
}
private parseHelperOutput(output: string): DatabaseKeyResult {
const payloads: Record<string, unknown>[] = []
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
try {
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
} catch {
// Ignore helper progress that is not JSON.
}
}
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
if (!isValidDatabaseKey(rawKey)) {
const errorPayload = payloads.find((item) => typeof item.result === 'string')
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
const code = parsedError?.[1]?.toUpperCase()
const detail = parsedError?.[2]?.trim() || ''
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
return {
success: false,
code,
error:
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
}
}
if (code === 'SCAN_FAILED') {
return {
success: false,
code,
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
}
}
return {
success: false,
code,
error: rawError || '密钥工具未返回有效的 64 位密钥'
}
}
return { success: true, key: rawKey }
throw new Error('未找到微信主进程,请先启动微信并停留在登录界面')
}
async autoGetDbKey(
onStatus?: (message: string) => void,
timeoutMs = 60_000
timeoutMs = 60_000,
accountRoot?: string
): Promise<DatabaseKeyResult> {
if (onStatus) {
const emitStatus = onStatus
onStatus = (message: string): void =>
emitStatus(message.replace(/Frida/gi, '连接组件'))
}
if (process.platform !== 'darwin') {
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
}
if (await this.isSipEnabled()) {
return {
success: false,
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
}
return { success: false, code: 'SIP_ENABLED', error: SIP_ENABLED_ERROR }
}
try {
if (process.arch === 'x64') {
onStatus?.('Intel Mac 将通过 Frida 获取微信主密钥,请让微信停留在未登录界面')
const result = await this.captureIntelDbKey(accountRoot, timeoutMs, onStatus)
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result
}
const wechatVersion = await detectWechatVersion()
onStatus?.('正在查找微信进程...')
const pid = await this.getWeChatPid()
const helperPath = this.getHelperPath()
const waitMs = Math.max(30_000, timeoutMs)
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
const invocation = buildAppleSiliconXkeyInvocation(wechatVersion, pid, timeoutMs)
const isWechat413 = invocation.mode === 'wechat-4.1.13'
const helperPath = this.getHelperPath(invocation.resourceName)
onStatus?.('正在请求管理员授权...')
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
`set timeoutSec to ${timeoutSeconds}`,
`set cmd to quoted form of helperPath & " ${invocation.args.join(' ')}"`,
`set timeoutSec to ${invocation.timeoutSeconds}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
@@ -141,25 +385,57 @@ export class KeyServiceMac {
'return "ERR::" & errNum & "::" & errMsg',
'end try'
]
onStatus?.('授权后 需要在微信登录界面 点击登录微信')
onStatus?.(
isWechat413
? '授权后请在微信登录界面点击“登录”,已有登录凭据时通常不需要扫码'
: '授权后 需要在微信登录界面 点击登录微信'
)
const { stdout } = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap((line) => ['-e', line]),
{ timeout: waitMs + 20_000 }
{ timeout: invocation.execTimeoutMs }
)
const output = String(stdout).trim()
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
if (output.startsWith('ERR::')) {
return {
success: false,
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
}
if (output.startsWith('ERR::-128')) {
return { success: false, error: '已取消管理员授权' }
}
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
if (output.startsWith('ERR::')) {
const [, errorNumber = 'UNKNOWN', ...errorParts] = output.split('::')
const result = mapXkeyHelperFailure(errorParts.join('::'), `OSASCRIPT_${errorNumber}`)
onStatus?.('密钥获取失败')
return result
}
const result = parseXkeyHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
const processError = error as NodeJS.ErrnoException & {
killed?: boolean
signal?: NodeJS.Signals | null
}
if (processError.message?.includes('未找到微信主进程')) {
return {
success: false,
code: 'WECHAT_NOT_RUNNING',
error: '未找到微信主进程,请先启动微信并停留在登录界面。'
}
}
if (
processError.killed ||
processError.code === 'ETIMEDOUT' ||
processError.signal === 'SIGTERM'
) {
return {
success: false,
code: 'AUTH_TIMEOUT',
error: '管理员授权等待超时,请点击“自动获取密钥”后及时完成系统授权。'
}
}
return {
success: false,
code: typeof processError.code === 'string' ? processError.code : 'HELPER_EXEC_FAILED',
error: '密钥工具执行失败,请确认微信仍在运行后重试。'
}
}
}
+90 -5
View File
@@ -1,10 +1,15 @@
import { join, dirname, delimiter } from 'path'
import { existsSync, copyFileSync, mkdirSync } from 'fs'
import { existsSync, copyFileSync, mkdirSync, readdirSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
import os from 'os'
import crypto from 'crypto'
import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
import {
deriveV4ImageKeys,
extractKvcommCode,
normalizeV4AccountId
} from '../shared/wechat-image-key-derivation'
const execFileAsync = promisify(execFile)
@@ -683,7 +688,8 @@ export class KeyService {
if (loginRequired) {
return {
success: false,
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
error:
'微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
logs
}
}
@@ -696,8 +702,7 @@ export class KeyService {
onProgress?: (message: string) => void,
wxidParam?: string
): Promise<ImageKeyResult> {
void wxidParam
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress, wxidParam)
}
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
@@ -706,7 +711,8 @@ export class KeyService {
async autoGetImageKeyByMemoryScan(
userDir: string,
onProgress?: (message: string) => void
onProgress?: (message: string) => void,
wxidParam?: string
): Promise<ImageKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
@@ -781,6 +787,18 @@ export class KeyService {
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
const derived = this._deriveImageKeyByLocalMetadata(
userDir,
wxidParam,
ciphertext,
xorKey,
onProgress
)
if (derived) {
onProgress?.('通过本机账号元数据推导并验证图片密钥成功')
return { success: true, xorKey: derived.xorKey, aesKey: derived.aesKey, verified: true }
}
// 2. 找微信 PID
const pid = await this.findWeChatPid()
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
@@ -811,6 +829,73 @@ export class KeyService {
}
}
private _deriveImageKeyByLocalMetadata(
userDir: string,
wxidParam: string | undefined,
ciphertext: Buffer,
expectedXorKey: number,
onProgress?: (message: string) => void
): { xorKey: number; aesKey: string } | null {
const codes = new Set<number>()
for (const directory of this._getKvcommCandidates(userDir)) {
try {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (!entry.isFile()) continue
const code = extractKvcommCode(entry.name)
if (code !== null) codes.add(code)
}
} catch {
// Candidate paths differ across WeChat releases and installations.
}
}
const accountIds = new Set<string>()
for (const candidate of [wxidParam, userDir]) {
const normalized = normalizeV4AccountId(candidate || '')
if (normalized) accountIds.add(normalized)
}
onProgress?.(`正在校验本机账号元数据候选(code=${codes.size}, account=${accountIds.size})...`)
for (const accountId of accountIds) {
for (const code of codes) {
const derived = deriveV4ImageKeys(code, accountId)
if (!derived || derived.xorKey !== expectedXorKey) continue
if (this._verifyAesKey(Buffer.from(derived.aesKey, 'ascii'), ciphertext)) return derived
}
}
return null
}
private _getKvcommCandidates(userDir: string): string[] {
const candidates: string[] = []
const seen = new Set<string>()
const add = (candidate: string | undefined) => {
if (!candidate) return
const normalized = candidate.replace(/[\\/]+$/, '')
const identity = normalized.toLowerCase()
if (!normalized || seen.has(identity)) return
seen.add(identity)
candidates.push(normalized)
}
const roaming = process.env.APPDATA
const local = process.env.LOCALAPPDATA
add(roaming && join(roaming, 'Tencent', 'xwechat', 'net', 'kvcomm'))
add(roaming && join(roaming, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
add(local && join(local, 'Tencent', 'xwechat', 'net', 'kvcomm'))
add(local && join(local, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
add(local && join(local, 'Tencent', 'WeChat', 'xwechat', 'net', 'kvcomm'))
let cursor = userDir
for (let depth = 0; cursor && depth < 6; depth++) {
add(join(cursor, 'net', 'kvcomm'))
const parent = dirname(cursor)
if (parent === cursor) break
cursor = parent
}
return candidates
}
private async _findTemplateData(
userDir: string,
limit: number = 32
+616 -82
View File
@@ -1,8 +1,12 @@
import { monitorEventLoopDelay } from 'perf_hooks'
import * as chat from '../services/chat-service'
import type {
KnowledgeImageOcrState,
KnowledgeAttachmentMetadata,
KnowledgeEvidence,
KnowledgeMessageKind,
KnowledgePassProgress,
KnowledgeRuntimeState,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchIpcRequest,
@@ -21,6 +25,7 @@ import {
emptyKnowledgeSearchTimings
} from '../../shared/knowledge'
import { KnowledgeService } from './knowledge-service'
import { sourceMessageId } from './message-identity'
import {
voiceAccountIdentity,
voiceMessageIdentity
@@ -32,6 +37,42 @@ const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
const MAX_SENDER_ENRICHMENT_SESSIONS = 32
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000
/**
* 增量读取时向前回看的 overlap(epoch ms)。
*
* delta 下界 = `checkpoint - DELTA_OVERLAP_MS`,用来吸收 timestamp 边界碰撞。
* 它必须远大于 chunker 的 `maxGapMs`,否则跨越下界的 chunk 会缺前半段消息、重建时丢前文。
*
* ⚠️ 单位:这里是**毫秒**(checkpoint 本身是毫秒)。传给 WCDB 读取层之前必须换成
* epoch **秒**(`chat.listMessagesAsync` 的 start/end 是秒)。混用会让 delta 读恒为空,
* 且**不会报错**。
*/
const DELTA_OVERLAP_MS = 24 * 60 * 60 * 1000
/**
* event-loop 滞后采样的分辨率(ms)。
*
* 用 `monitorEventLoopDelay` 的 histogram 而不是手写 setInterval:后者只能以 interval
* 为粒度发现 stall,给不出分位数。
*/
const LAG_PROBE_RESOLUTION_MS = 10
/** histogram 的纳秒读数转毫秒。 */
function roundMs(nanoseconds: number): number {
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) return 0
return Math.round(nanoseconds / 1e6)
}
/** WCDB 读取通道:`interactive` = 交互查询,`background` = 后台索引 pass。 */
type WcdbReadLane = 'interactive' | 'background'
type PendingWcdbRead = {
high: boolean
run: () => Promise<unknown>
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
type PendingVoiceTranscriptIndex = {
update: VoiceTranscriptUpdate
waiters: Array<{
@@ -43,7 +84,13 @@ type PendingVoiceTranscriptIndex = {
type SenderEnrichmentSession = {
lastUsedAt: number
contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>>
groupSnapshots: Map<string, Awaited<ReturnType<typeof chat.getGroupSnapshotAsync>> | undefined>
/**
* conversationId → (wxid → displayName)。
*
* 只缓存"这个群里这些 wxid 解析出来是什么名字",不再缓存整群快照。
* 空串表示「查过、确实没有可用名字」,用于避免同一 session 内重复查询。
*/
groupMemberNames: Map<string, Map<string, string>>
}
function looksLikeOpaqueSenderId(value: string | undefined): boolean {
@@ -73,14 +120,23 @@ function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]):
)
}
function sourceMessageId(message: chat.FormattedMessage): string {
if (message.localId) return `local:${message.localId}`
if (message.id) return String(message.id)
return `${message.createTime || 0}:${message.serverId || message.content}`
}
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice'
if (message.exportMediaType === 'image' || message.exportMediaType === 'video' || message.exportMediaType === 'sticker') {
return message.exportMediaType
}
if (message.exportMediaType === 'file') return 'file'
// 索引路径上 `exportMediaType` **不会被赋值**(只有 export-service 会设它),
// 所以图片/视频/表情包必须从 contentData.type 判定,否则图片会静默落成 'other',
// 进而让"图片文字索引"的 Evidence 丢掉真正的来源类型。
if (
message.contentData?.type === 'image' ||
message.contentData?.type === 'video' ||
message.contentData?.type === 'sticker'
) {
return message.contentData.type
}
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file'
@@ -152,12 +208,16 @@ function toSourceMessage(
accountId: string,
conversationId: string,
message: chat.FormattedMessage,
transcriptOverride?: string
transcriptOverride?: string,
imageOcr?: { state: KnowledgeImageOcrState; text: string }
): KnowledgeSourceMessage | null {
if (!message.createTime) return null
const extracted = sourceTextAndAttachment(message)
const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined
if (!extracted.text && !extracted.attachment && !voiceTranscript) return null
// 图片 OCR 文本走与语音转写完全相同的派生通道:有文本才入库,
// 没有文字的图片(表情包/风景)不会污染索引。
const imageOcrText = imageOcr?.text?.trim() || undefined
if (!extracted.text && !extracted.attachment && !voiceTranscript && !imageOcrText) return null
return {
accountId,
conversationId,
@@ -169,7 +229,9 @@ function toSourceMessage(
kind: sourceKind(message),
text: extracted.text,
attachment: extracted.attachment,
voiceTranscript
voiceTranscript,
...(imageOcrText ? { imageOcrText } : {}),
...(imageOcrText && imageOcr?.state ? { imageOcrState: imageOcr.state } : {})
}
}
@@ -199,15 +261,39 @@ export class KnowledgeSearchService {
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>()
private wcdbReadTail: Promise<void> = Promise.resolve()
/** WCDB 读取的两条通道:交互(查询)优先于后台(索引 pass)。 */
private readonly wcdbPending: PendingWcdbRead[] = []
private wcdbReadBusy = false
private interactiveQueryDepth = 0
private interactiveIdle: Promise<void> = Promise.resolve()
private interactiveIdleResolve: (() => void) | null = null
private wcdbQueueMsTotal = 0
private wcdbExecutionMsTotal = 0
/**
* 图片 OCR 文本解析器(由 main 注入)。
*
* 与语音同构:派生文本在**主进程**解析后贴到消息上,派生库不进 worker。
*/
private imageOcrResolver:
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
| undefined
private voiceTranscriptResolver:
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
| undefined
private voiceIndexTail: Promise<void> = Promise.resolve()
private voiceIndexFlushScheduled = false
private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>()
/** 上次由查询触发的追赶同步时间,用于节流(避免每个 Query 都重跑一次索引)。 */
private lastCatchUpRequestedAt = 0
/** 上一遍完整索引 pass 的实际耗时;用于让"是否值得再追一遍"的门槛自我校准。 */
private lastIndexPassMs = 0
/** 当前/最近一次 pass 的真实进度(供 UI 区分"追新"与"补历史")。 */
private passProgress: KnowledgePassProgress | null = null
/** 用户是否已经请求取消当前 pass。取消后主循环在下一个安全点退出。 */
private cancelRequested = false
private lagHistogram: ReturnType<typeof monitorEventLoopDelay> | null = null
private lagMaxMs = 0
private lagStats: { p50: number; p95: number; p99: number; max: number } | null = null
constructor(userDataPath: string, workerPath: string) {
this.service = new KnowledgeService(userDataPath, workerPath)
@@ -218,13 +304,32 @@ export class KnowledgeSearchService {
if (!accountId) return this.emptyStatus('')
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
if (this.indexing.has(accountId)) return current
this.cancelRequested = false
const startedAt = Date.now()
this.startLagProbe()
this.passProgress = {
// 已经有分片 = 增量追新;完全没有 = 首次全量建立。
phase: current.indexedChunkCount > 0 || current.indexedMessageCount > 0 ? 'catchup' : 'full',
cancellable: true,
startedAt,
scannedMessages: 0,
indexedMessages: 0,
processedConversations: 0,
totalConversations: 0,
skippedConversations: 0,
catchupConversations: 0,
backfillConversations: 0,
backfillCompletedConversations: 0,
mainLoopLagMs: 0
}
const started: KnowledgeRuntimeStatus = {
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0,
totalMessages: current.sourceMessageCount,
estimatedRemainingMs: null,
lastError: undefined
lastError: undefined,
pass: { ...this.passProgress }
}
this.publishStatus(started)
const task = this.indexAccount(accountId)
@@ -239,6 +344,16 @@ export class KnowledgeSearchService {
})
.finally(() => {
this.indexing.delete(accountId)
this.stopLagProbe()
const finishedPass = this.passProgress
if (finishedPass) {
// 真实结束状态:取消就是取消,绝不留一个假的 indexing。
finishedPass.cancellable = false
finishedPass.mainLoopLagMs = this.lagMaxMs
if (finishedPass.phase !== 'cancelled' && finishedPass.phase !== 'error') {
finishedPass.phase = 'idle'
}
}
void this.refreshStatus(accountId).catch(() => undefined)
})
this.indexing.set(accountId, task)
@@ -248,6 +363,26 @@ export class KnowledgeSearchService {
return started
}
/**
* 取消当前正在跑的索引 pass。
*
* 只中止**索引**(与并发查询是两套独立 Abort scope);当前 batch 安全收尾、
* 已提交会话保留不回滚;`run_state` 落到 `cancelled`,不残留 `indexing`;
* 下一次 catch-up / 手动同步从 per-conversation checkpoint 继续,不从头全量重扫。
*/
async cancelCurrentAccountIndex(): Promise<{ cancellable: boolean; cancelled: boolean }> {
if (!this.indexing.size) return { cancellable: false, cancelled: false }
this.cancelRequested = true
if (this.passProgress) this.passProgress.cancellable = false
const accountId = this.currentAccountId()
if (accountId) {
const current = this.statusByAccount.get(accountId)
if (current) this.publishStatus({ ...current, pass: this.passSnapshot() })
}
const cancelled = await this.service.cancelIndex().catch(() => false)
return { cancellable: true, cancelled }
}
/**
* The voice cache remains owned by the voice pipeline. Knowledge only reads
* a current-account snapshot while constructing a derived local index.
@@ -258,6 +393,15 @@ export class KnowledgeSearchService {
this.voiceTranscriptResolver = resolver
}
/** 注入图片 OCR 文本解析器(本地 System OCR 的派生结果)。 */
setImageOcrResolver(
resolver:
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
| undefined
): void {
this.imageOcrResolver = resolver
}
/**
* A successful recognition updates its source conversation. Consecutive
* updates for the same conversation are coalesced because a complete
@@ -316,8 +460,10 @@ export class KnowledgeSearchService {
}
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
// 源数据最新活跃时间与索引状态无关,先取一次(零额外 WCDB 调用:读的是已缓存的 Session 列表)。
const sourceLatestAt = this.sourceLatestAt()
const accountId = this.currentAccountId()
if (!accountId) return this.searchFallback(request, 'unavailable')
if (!accountId) return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
try {
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
accountId,
@@ -329,24 +475,87 @@ export class KnowledgeSearchService {
senderIds: request.senderIds,
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
,conversationBoundary: request.conversationBoundary
}
const result = await this.searchKnowledge(searchRequest)
// An existing derived database can answer while its next incremental pass is running.
// Never turn an interactive global search into another full WCDB scan during that pass.
if (result.state === 'ready' || result.evidence.length) {
return this.toKnowledgeResult(result, request.retrievalSessionId)
return { ...(await this.toKnowledgeResult(result, request.retrievalSessionId)), sourceLatestAt }
}
if (this.indexing.has(accountId)) {
return {
...result,
source: 'knowledge',
totalMessages: result.indexedMessageCount
totalMessages: result.indexedMessageCount,
sourceLatestAt
}
}
return this.searchFallback(request, 'unavailable')
return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
} catch (error) {
console.warn('[Knowledge] search failed, using legacy fallback:', error)
return this.searchFallback(request, 'error')
return { ...(await this.searchFallback(request, 'error')), sourceLatestAt }
}
}
/**
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
*/
sourceLatestAt(): number | null {
return chat.getSourceLatestActivityMs()
}
/**
* 上一遍完整索引 pass 的耗时(ms)。0 表示本进程还没有跑完过一遍。
* 调用方用它作为"落后多少才值得再追一遍"的门槛下限,避免在活跃源数据上无限连续索引。
*/
lastPassDurationMs(): number {
return this.lastIndexPassMs
}
/** 当前是否有索引任务在跑(用于「复用当前任务」而不是再启动一个)。 */
isIndexing(): boolean {
// 用 Map 是否为空判断,而不是用当前 accountId 去查:
// `currentAccountId()` 会在 `getSelfAccountInfo()` 就绪前后返回不同的值,
// 只按单键查会漏掉"其实已经有 pass 在跑",于是又启动一个(两个 pass 抢同一个派生库)。
return this.indexing.size > 0
}
/**
* 主动请求一次追赶同步。
*
* 复用现有增量通道(`startCurrentAccountIndex` 内部是增量的:未变化的会话不会重建分片);
* 如果已经在跑就**不**再启动第二个,并把 `triggered` 标为 false,让调用方知道这是复用。
*/
requestCatchUp(minIntervalMs: number): { triggered: boolean; inProgress: boolean } {
const accountId = this.currentAccountId()
if (!accountId) return { triggered: false, inProgress: false }
if (this.isIndexing()) return { triggered: false, inProgress: true }
const now = Date.now()
if (now - this.lastCatchUpRequestedAt < minIntervalMs) {
return { triggered: false, inProgress: false }
}
this.lastCatchUpRequestedAt = now
this.startCurrentAccountIndex()
return { triggered: true, inProgress: this.isIndexing() }
}
/**
* 等当前索引任务结束,最多等 `budgetMs`。返回是否已经结束。
* 全量追赶可能远超查询预算,所以这里必须是**有界**等待,不能无限阻塞交互查询。
*/
async waitForIndexingComplete(budgetMs: number): Promise<boolean> {
const task = this.indexing.values().next().value as Promise<void> | undefined
if (!task) return true
if (budgetMs <= 0) return false
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), budgetMs)
})
try {
return await Promise.race([task.then(() => true, () => true), timeout])
} finally {
if (timer) clearTimeout(timer)
}
}
@@ -382,66 +591,244 @@ export class KnowledgeSearchService {
}
private async indexAccount(accountId: string): Promise<void> {
const contacts = await this.listContacts()
let processedMessages = 0
// 整遍后台索引走 background 通道:交互查询可以插到它前面,不至于被 pass 拖慢。
const contacts = await this.listContacts('background')
const startedAt = Date.now()
// 源侧每会话最后活跃时间(零额外 WCDB 调用:直接来自 Session 列表)。
const activity = chat.getConversationActivityMs()
// 每个会话已经索引到哪(per-conversation checkpoint)。
const marks = await this.service
.highWaterMarks({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
.catch(() => ({}) as Record<string, number>)
// 上一遍记录的源侧边界:被跳过的会话已覆盖到它,不能因为"这一遍没读"而回退。
const previouslyCoveredLatestAt =
this.statusByAccount.get(accountId)?.indexLatestAt ?? 0
let scannedMessages = 0
let indexedMessages = 0
let sourceLatestAt = previouslyCoveredLatestAt
let skippedConversations = 0
let backfillCompletedConversations = 0
// 「追最新」与「补历史」是两个工作概念,顺序不能反:
// catch-up:已建立 checkpoint、源侧出现新消息 → 用户最关心,排在最前;
// backfill:从来没有 checkpoint(历史缺口)→ 必须整段读,排在后面慢慢补。
// 这个顺序保证今天的新消息不会被历史缺口堵住。
const catchUpContacts: typeof contacts = []
const backfillContacts: typeof contacts = []
for (const contact of contacts) {
const mark = marks[contact.md5]
if (mark !== undefined && mark > 0) {
const previousActivity = activity.get(contact.md5)
// 源侧最后活跃时间不晚于"已经索引到的位置"→ 这个会话没有任何新消息。
// 直接跳过:**不读 WCDB、不传 IPC、不写索引**。
// 这正是把「一次 pass 处理百万级消息」变成「只处理真正变过的会话」的地方。
if (previousActivity !== undefined && previousActivity <= mark) {
skippedConversations += 1
continue
}
catchUpContacts.push(contact)
continue
}
// 没有 checkpoint → 没有"已经覆盖到哪"的证据,只能整段读(历史 backfill)。
//
// 这里**不**用「源侧活动(Session 表)里没有它」推断「它不可能有消息」。
// 那确实能省掉读一批空联系人的开销,但只要 Session 表在某次读取里不完整
// (或用户删过会话),这个推断就会把一个真有消息的会话变成**永久静默不索引**。
// 省下来的时间换不来这个风险。
backfillContacts.push(contact)
}
const orderedContacts = [...catchUpContacts, ...backfillContacts]
if (this.passProgress) {
this.passProgress.totalConversations = contacts.length
this.passProgress.startedAt = startedAt
this.passProgress.skippedConversations = skippedConversations
this.passProgress.catchupConversations = catchUpContacts.length
this.passProgress.backfillConversations = backfillContacts.length
this.passProgress.backfillCompletedConversations = 0
this.passProgress.processedConversations = skippedConversations
}
this.publishStatus({
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0,
totalMessages: null,
estimatedRemainingMs: null
estimatedRemainingMs: null,
pass: this.passSnapshot()
})
for (const [index, contact] of contacts.entries()) {
let cancelled = false
for (const [index, contact] of orderedContacts.entries()) {
if (this.cancelRequested) {
cancelled = true
break
}
// 交互查询进行中就让路:背景索引绝不能把用户查询拖慢(见 beginInteractiveQuery)。
await this.interactiveIdle
const isBackfill = index >= catchUpContacts.length
if (this.passProgress) {
this.passProgress.phase = isBackfill ? 'backfill' : 'catchup'
this.passProgress.processedConversations = skippedConversations + index
}
const previousActivity = activity.get(contact.md5)
const mark = marks[contact.md5]
// WCDB rejects overlapping async pagination. Queue every archive read so
// background indexing and an interactive fallback search can interleave safely.
const messages = await this.listMessages(contact.md5)
//
// delta 读取:已建立 checkpoint 时只读 checkpoint 之后的源消息(外加有界 overlap),
// 而不是"从历史开头全扫一遍、再判断哪些已经索引过"。
//
// ⚠️ 单位契约见 `DELTA_OVERLAP_MS`:checkpoint 是**毫秒**,传给 WCDB 读取层
// 必须是**秒**,否则 delta 读会被静默过滤成空。
const isDelta = mark !== undefined && mark > 0
const sinceTime = isDelta
? Math.max(0, Math.floor((mark - DELTA_OVERLAP_MS) / 1000))
: undefined
const messages = await this.listMessages(contact.md5, sinceTime, undefined, 'background')
scannedMessages += messages.length
// 这一遍扫到的源数据最新时间:用**未过滤**的原始消息计算,
// 这样「不可建模」的消息(图片/空正文)不会让 freshness 口径偏旧。
let conversationLatest = 0
for (const message of messages) {
const createTime = (message.createTime || 0) * 1000
if (createTime > conversationLatest) conversationLatest = createTime
}
if (conversationLatest > sourceLatestAt) sourceLatestAt = conversationLatest
const sourceMessages = messages
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
await this.service.index(
// 记录**源侧**边界(而不是索引里最后一条可建模消息的时间):
// 否则"最后一条恰好落在图片上"的会话会永远被判成有新消息,增量永远跳不过它。
//
// 安全阀:delta 范围读**空**、但源侧声称有新消息 → **不推进** checkpoint。
// 宁可下一遍重试,也不让"一次可疑的空读"升级成"谎报已覆盖"(那会静默丢消息)。
const suspiciousEmptyDelta =
isDelta && messages.length === 0 && (previousActivity ?? 0) > (mark ?? 0)
const sourceHighWaterTime = suspiciousEmptyDelta
? undefined
: Math.max(previousActivity ?? 0, conversationLatest)
const result = await this.service.index(
{
accountId,
conversations: [
{
conversationId: contact.md5,
completeSnapshot: true,
messages: sourceMessages
// delta 模式下绝不能声明"完整快照":否则 store 会把"不在 delta 里的历史消息"
// 误判为被删除,从而整段重建这个会话,增量就白做了。
completeSnapshot: !isDelta,
messages: sourceMessages,
...(sourceHighWaterTime && sourceHighWaterTime > 0 ? { sourceHighWaterTime } : {})
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
// 只有「这一遍真的读完了全部会话」(没有任何跳过、没有取消)才写入总量口径,
// 否则会把增量 pass 的部分计数冒充成全量。
//
// 这里数的是真正被建模进索引的源消息,不是"扫到的原始条数";后者(含不可建模的
// 图片/空正文)另走 pass 进度里的 scannedMessages。两个数字回答不同问题。
sourceMessageCount:
index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined
index === orderedContacts.length - 1 && skippedConversations === 0
? indexedMessages + sourceMessages.length
: undefined,
sourceLatestAt:
index === orderedContacts.length - 1 && sourceLatestAt > 0 ? sourceLatestAt : undefined
},
(progress) => {
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: processedMessages + progress.processedMessages,
processedMessages: indexedMessages + progress.processedMessages,
totalMessages: null,
currentConversationId: progress.conversationId,
estimatedRemainingMs: null
estimatedRemainingMs: null,
pass: this.passSnapshot()
})
}
)
processedMessages += sourceMessages.length
if (result.cancelled) {
cancelled = true
indexedMessages += result.processedMessages
break
}
indexedMessages += sourceMessages.length
if (isBackfill) backfillCompletedConversations += 1
if (this.passProgress) {
this.passProgress.scannedMessages = scannedMessages
this.passProgress.indexedMessages = indexedMessages
this.passProgress.processedConversations = skippedConversations + index + 1
this.passProgress.backfillCompletedConversations = backfillCompletedConversations
}
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages,
processedMessages: indexedMessages,
totalMessages: null,
currentConversationId: contact.md5,
estimatedRemainingMs: null
estimatedRemainingMs: null,
pass: this.passSnapshot()
})
}
if (cancelled && this.passProgress) this.passProgress.phase = 'cancelled'
// 一遍 pass 一行汇总(低频、只在结束时输出一次)。这些数字同时喂给 UI 的 pass 进度;
// lag 输出分位数而不是单点,因为"最大值看着还行"不能说明没有 stall。
console.info(
`[Knowledge] pass ${cancelled ? 'cancelled' : 'done'} conversations=${contacts.length} ` +
`skipped=${skippedConversations} scanned=${scannedMessages} indexed=${indexedMessages} ` +
`catchup=${catchUpContacts.length} backfill=${backfillContacts.length}/${backfillCompletedConversations} ` +
`elapsedMs=${Date.now() - startedAt} ` +
`lagP50=${this.lagStats?.p50 ?? 0} lagP95=${this.lagStats?.p95 ?? 0} ` +
`lagP99=${this.lagStats?.p99 ?? 0} lagMax=${this.lagStats?.max ?? this.lagMaxMs} ` +
`activityEntries=${activity.size} checkpointMarks=${Object.keys(marks).length}`
)
await this.refreshStatus(accountId, {
processedMessages,
totalMessages: processedMessages,
processedMessages: indexedMessages,
totalMessages: null,
startedAt
})
// 取消的一遍不计入"上一遍耗时",否则查询侧的门槛会被一次提前结束的 pass 带偏。
if (!cancelled) this.lastIndexPassMs = Date.now() - startedAt
}
/** 当前 pass 进度的不可变快照(含实时采样到的主线程滞后)。 */
private passSnapshot(): KnowledgePassProgress | undefined {
if (!this.passProgress) return undefined
// 运行期间也要能读到"此刻为止"的最大滞后,而不是等 pass 结束才有数字。
const liveMax = this.lagHistogram ? roundMs(this.lagHistogram.max) : this.lagMaxMs
return { ...this.passProgress, mainLoopLagMs: Math.max(liveMax, 0) }
}
/**
* 在 pass 开始时启动主线程滞后采样。这是"重活没有压在主线程上"的直接证据。
*/
private startLagProbe(): void {
if (this.lagHistogram) return
this.lagMaxMs = 0
this.lagStats = null
try {
const histogram = monitorEventLoopDelay({ resolution: LAG_PROBE_RESOLUTION_MS })
histogram.enable()
this.lagHistogram = histogram
} catch {
// 采样失败不应该影响索引本身;退化为"没有 lag 数据"。
this.lagHistogram = null
}
}
private stopLagProbe(): void {
const histogram = this.lagHistogram
if (!histogram) return
histogram.disable()
this.lagStats = {
p50: roundMs(histogram.percentile(50)),
p95: roundMs(histogram.percentile(95)),
p99: roundMs(histogram.percentile(99)),
max: roundMs(histogram.max)
}
this.lagMaxMs = Math.max(this.lagMaxMs, this.lagStats.max)
this.lagHistogram = null
}
private async searchFallback(
@@ -481,12 +868,14 @@ export class KnowledgeSearchService {
.filter(({ message, score }) => {
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
const termMatches = !terms.length || score > 0
return senderMatches && termMatches
const boundaryMatches = !request.conversationBoundary || message.contentData?.type !== 'system'
return senderMatches && termMatches && boundaryMatches
})
.sort(
(left, right) =>
right.score - left.score ||
(right.message.createTime || 0) - (left.message.createTime || 0)
(request.conversationBoundary === 'first' ? -1 : 1) *
((right.message.createTime || 0) - (left.message.createTime || 0))
)
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
const result: KnowledgeSearchIpcResult = {
@@ -495,6 +884,9 @@ export class KnowledgeSearchService {
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0,
// fallback 是直接扫源数据,不走派生索引,因此没有索引覆盖口径可言。
indexLatestAt: null,
sourceLatestAt: this.sourceLatestAt(),
totalMessages,
timings: {
...emptyKnowledgeSearchTimings(),
@@ -632,6 +1024,9 @@ export class KnowledgeSearchService {
voiceCoverage.voiceCoverageComplete =
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
}
const indexLatestParts = partialResults
.map((result) => result.indexLatestAt)
.filter((value): value is number => typeof value === 'number' && value > 0)
return {
state: partialResults.some((result) => result.state === 'ready')
? 'ready'
@@ -640,6 +1035,8 @@ export class KnowledgeSearchService {
: 'unavailable',
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
// 多个分片取最新的那个:只要有一部分索引更新,整体覆盖口径就按它算。
indexLatestAt: indexLatestParts.length ? Math.max(...indexLatestParts) : null,
evidence: mergedEvidence,
timings,
voiceCoverage
@@ -673,16 +1070,20 @@ export class KnowledgeSearchService {
}
}
private listContacts(): ReturnType<typeof chat.listContactsAsync> {
return this.enqueueWcdbRead(() => chat.listContactsAsync())
private listContacts(lane: WcdbReadLane = 'interactive'): ReturnType<typeof chat.listContactsAsync> {
return this.enqueueWcdbRead(() => chat.listContactsAsync(), lane)
}
private listMessages(
conversationId: string,
startTime?: number,
endTime?: number
endTime?: number,
lane: WcdbReadLane = 'interactive'
): ReturnType<typeof chat.listMessagesAsync> {
return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime))
return this.enqueueWcdbRead(
() => chat.listMessagesAsync(conversationId, startTime, endTime, undefined, undefined, 'knowledge'),
lane
)
}
private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage {
@@ -703,8 +1104,16 @@ export class KnowledgeSearchService {
const reference = this.voiceReferenceFromMessage(message)
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
const hydrated = this.withVoiceTranscript(message)
const source = toSourceMessage(accountId, conversationId, hydrated, transcriptOverride)
if (!source || source.kind !== 'voice') return source
const imageOcr = this.imageOcrResolver?.(conversationId, sourceMessageId(message))
const source = toSourceMessage(
accountId,
conversationId,
hydrated,
transcriptOverride,
imageOcr
)
if (!source) return source
if (source.kind !== 'image' && source.kind !== 'voice') return source
return {
...source,
voiceTranscriptState:
@@ -734,6 +1143,38 @@ export class KnowledgeSearchService {
}
}
/**
* 某个会话的图片 OCR 处理完成 → 重建该会话的索引。
*
* 与"语音转写完成后单会话重索引"完全同构:整会话重读 + completeSnapshot 重建,
* 让 OCR 派生文本进入 chunks/FTS,从而可被 search_messages 检索。
* 原图片消息仍然是 authoritative source —— 这里只是让它多了一段派生文本,
* 不产生任何"OCR 消息"。
*/
async indexImageOcr(conversationId: string): Promise<void> {
if (!chat.isReady()) return
const accountId = this.currentAccountId()
if (!accountId) return
const activeIndex = this.indexing.get(accountId)
if (activeIndex) await activeIndex
const contacts = await this.listContacts()
const contact = contacts.find((item) => item.md5 === conversationId)
if (!contact) return
const messages = await this.listMessages(contact.md5, undefined, undefined, 'background')
const sourceMessages = messages
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
await this.service.index({
accountId,
conversations: [
{ conversationId: contact.md5, completeSnapshot: true, messages: sourceMessages }
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
})
await this.refreshStatus(accountId)
}
private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise<void> {
if (!chat.isReady()) return
if (update.state === 'transcribed' && !update.transcript?.trim()) return
@@ -804,22 +1245,36 @@ export class KnowledgeSearchService {
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
},
source: 'knowledge',
totalMessages: result.indexedMessageCount
totalMessages: result.indexedMessageCount,
sourceLatestAt: this.sourceLatestAt()
}
}
/**
* Evidence 的 sender 显示名 enrichment。
*
* 只做「取名字」这一件事:按 conversation 聚合 evidence 真正需要的 wxid(不是整群成员),
* 每群一次批量 name lookup(`getGroupMemberNamesAsync`),**不**构造完整 GroupSnapshot、
* **不** hydrate 头像 —— 后者会把整群成员的头像一起读出来,为拿几个名字付整群成本。
*
* 头像不属于 Query Tool 的成本;若 Evidence UI 将来要头像,走 lazy 路径。
*/
private async enrichEvidenceSenders(
evidence: KnowledgeEvidence[],
retrievalSessionId?: string
): Promise<KnowledgeEvidence[]> {
const candidateConversationIds = Array.from(
new Set(
evidence
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender))
.map((item) => item.conversationId)
)
).slice(0, MAX_SENDER_NAME_CONVERSATIONS)
if (!candidateConversationIds.length) return evidence
// 先按会话聚合需要的 sender,避免"每条 evidence 一次调用"。
const wxidsByConversation = new Map<string, Set<string>>()
for (const item of evidence) {
if (!item.senderId || !looksLikeOpaqueSenderId(item.sender)) continue
let bucket = wxidsByConversation.get(item.conversationId)
if (!bucket) {
bucket = new Set<string>()
wxidsByConversation.set(item.conversationId, bucket)
}
bucket.add(item.senderId)
}
if (!wxidsByConversation.size) return evidence
const session = retrievalSessionId
? this.senderEnrichmentSession(retrievalSessionId)
@@ -829,20 +1284,35 @@ export class KnowledgeSearchService {
const groupConversationIds = new Set(
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
)
const candidateConversationIds = Array.from(wxidsByConversation.keys())
.filter((conversationId) => groupConversationIds.has(conversationId))
.slice(0, MAX_SENDER_NAME_CONVERSATIONS)
const memberNamesByConversation = new Map<string, Map<string, string>>()
for (const conversationId of candidateConversationIds) {
if (!groupConversationIds.has(conversationId)) continue
let snapshot = session?.groupSnapshots.get(conversationId)
if (!snapshot) {
snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId))
session?.groupSnapshots.set(conversationId, snapshot)
const requested = Array.from(wxidsByConversation.get(conversationId) || [])
if (!requested.length) continue
let memberNames = session?.groupMemberNames.get(conversationId)
// 只查缓存里还没有的 wxid —— 同一 session 的后续 probe 因此不会重复读 WCDB。
const missing = requested.filter((wxid) => !memberNames?.has(wxid))
if (missing.length) {
const members = await this.enqueueWcdbRead(() =>
chat.getGroupMemberNamesAsync(conversationId, missing)
)
if (!memberNames) {
memberNames = new Map<string, string>()
session?.groupMemberNames.set(conversationId, memberNames)
}
for (const member of members) {
memberNames.set(member.wxid, groupMemberDisplayName(member))
}
// 请求了但没有返回名字的 wxid 也标记为"查过",避免后续 probe 反复重查。
for (const wxid of missing) {
if (!memberNames.has(wxid)) memberNames.set(wxid, '')
}
}
const memberNames = new Map(
(snapshot?.members || [])
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
.filter(([, name]) => Boolean(name))
)
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
if (memberNames?.size) memberNamesByConversation.set(conversationId, memberNames)
}
return evidence.map((item) => {
@@ -860,7 +1330,7 @@ export class KnowledgeSearchService {
}
let session = this.senderEnrichmentSessions.get(retrievalSessionId)
if (!session) {
session = { lastUsedAt: now, groupSnapshots: new Map() }
session = { lastUsedAt: now, groupMemberNames: new Map() }
this.senderEnrichmentSessions.set(retrievalSessionId, session)
}
session.lastUsedAt = now
@@ -872,24 +1342,76 @@ export class KnowledgeSearchService {
return session
}
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> {
const enqueuedAt = Date.now()
const run = async (): Promise<T> => {
const startedAt = Date.now()
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
try {
return await operation()
} finally {
this.wcdbExecutionMsTotal += Date.now() - startedAt
}
/**
* 交互查询进行中:后台索引会让路。
*
* 追赶同步会自动遍历上千个会话;如果不让路,一次用户查询会和后台 pass 抢同一个
* Worker 与 WCDB 读取通道,被拖到几十秒 —— 查询不能因为索引 backlog 卡住。
*/
beginInteractiveQuery(): void {
if (this.interactiveQueryDepth === 0) {
this.interactiveIdle = new Promise<void>((resolve) => {
this.interactiveIdleResolve = resolve
})
}
const result = this.wcdbReadTail.then(run, run)
// Keep the queue usable after a read failure while returning that failure to its caller.
this.wcdbReadTail = result.then(
() => undefined,
() => undefined
)
return result
this.interactiveQueryDepth += 1
}
endInteractiveQuery(): void {
this.interactiveQueryDepth = Math.max(0, this.interactiveQueryDepth - 1)
if (this.interactiveQueryDepth === 0) {
this.interactiveIdleResolve?.()
this.interactiveIdleResolve = null
}
}
/**
* WCDB 异步分页不允许重叠,所有会话读取都必须串行。
*
* 但**后台索引**与**交互查询**不能同权排队:追赶同步会在后台遍历上千个会话,
* 交互读取排在它后面就会被拖成几十秒。因此分两条通道,交互读取优先于尚未开始的后台读取;
* 交互查询最多只等"一个正在执行的读"(WCDB 不允许重叠,这点无法避免)。
*/
private enqueueWcdbRead<T>(operation: () => Promise<T>, lane: WcdbReadLane = 'interactive'): Promise<T> {
const enqueuedAt = Date.now()
return new Promise<T>((resolve, reject) => {
const pending: PendingWcdbRead = {
high: lane === 'interactive',
run: async () => {
const startedAt = Date.now()
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
try {
return await operation()
} finally {
this.wcdbExecutionMsTotal += Date.now() - startedAt
}
},
resolve: resolve as (value: unknown) => void,
reject
}
if (pending.high) {
const firstBackground = this.wcdbPending.findIndex((item) => !item.high)
if (firstBackground < 0) this.wcdbPending.push(pending)
else this.wcdbPending.splice(firstBackground, 0, pending)
} else {
this.wcdbPending.push(pending)
}
this.drainWcdbReads()
})
}
private drainWcdbReads(): void {
if (this.wcdbReadBusy) return
const next = this.wcdbPending.shift()
if (!next) return
this.wcdbReadBusy = true
void next
.run()
.then(next.resolve, next.reject)
.finally(() => {
this.wcdbReadBusy = false
this.drainWcdbReads()
})
}
private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
@@ -904,7 +1426,10 @@ export class KnowledgeSearchService {
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null,
pass: this.passSnapshot()
}
}
@@ -917,20 +1442,29 @@ export class KnowledgeSearchService {
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
const current = this.statusByAccount.get(accountId)
const indexing = this.indexing.has(accountId)
const pass = this.passProgress
const processedMessages =
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
const state = indexing
// 一遍 pass 结束后,worker 只知道「派生库能不能查」,不知道这一遍是**被取消**还是**出错**。
// 这两个语义只在这里有(worker 侧的 run_state 已经落库),所以由本地 pass 覆盖,
// 避免取消之后又冒充成一个干净的 ready。
const state: KnowledgeRuntimeState = indexing
? remote.indexedMessageCount > 0
? 'syncing'
: 'building'
: remote.state
: pass && (pass.phase === 'cancelled' || pass.phase === 'error')
? pass.phase
: remote.state
const status: KnowledgeRuntimeStatus = {
...remote,
state,
processedMessages,
totalMessages,
estimatedRemainingMs: null
estimatedRemainingMs: null,
// 派生库自己看不到源数据;这里补上源侧最新活跃时间,UI 才能区分 READY 与 FRESH。
sourceLatestAt: this.sourceLatestAt(),
pass: this.passSnapshot()
}
this.publishStatus(status)
return status
+10
View File
@@ -48,6 +48,16 @@ export class KnowledgeService {
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
}
/** 每个会话已经索引到的源侧时刻(epoch ms);增量 pass 用它跳过没有变化的会话。 */
highWaterMarks(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<Record<string, number>> {
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
}
/** 只中止正在跑的索引任务;查询请求不受影响。 */
cancelIndex(): Promise<boolean> {
return this.worker.cancelActiveIndex()
}
dispose(): Promise<void> {
return this.worker.dispose()
}
+265 -24
View File
@@ -19,7 +19,11 @@ import type {
KnowledgeSearchTimings,
KnowledgeSearchResult
} from '../../shared/knowledge'
import { emptyKnowledgeSearchTimings, KNOWLEDGE_SCHEMA_VERSION } from '../../shared/knowledge'
import {
emptyKnowledgeSearchTimings,
KNOWLEDGE_SCHEMA_VERSION,
toEvidenceDisplayText
} from '../../shared/knowledge'
import { chunkConversation } from './chunker'
import { normalizeKnowledgeMessage } from './normalizer'
@@ -191,6 +195,7 @@ export class KnowledgeStore {
async index(
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
sourceMessageCount?: number
sourceLatestAt?: number
},
signal?: AbortSignal,
onProgress?: (progress: KnowledgeIndexProgress) => void
@@ -242,6 +247,9 @@ export class KnowledgeStore {
this.writeMeta('source_message_count', String(request.sourceMessageCount))
this.refreshStatsSnapshot()
}
if (request.sourceLatestAt !== undefined) {
this.writeMeta('source_latest_at', String(request.sourceLatestAt))
}
this.setRunState('ready')
return {
accountId: this.accountId,
@@ -293,23 +301,64 @@ export class KnowledgeStore {
}
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
this.ensureStatsSnapshot()
this.ensureStatsSnapshotForQuery()
const indexedMessageCount = this.readStatNumber('stats_message_count')
const indexedChunkCount = this.readStatNumber('stats_chunk_count')
const runState = this.readMeta('run_state')
return {
state:
runState === 'indexing'
? 'indexing'
: runState === 'ready' && indexedChunkCount > 0
? 'ready'
: 'unavailable',
// READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在
// 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。
// 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。
state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable',
indexedMessageCount,
indexedChunkCount,
indexLatestAt: this.readIndexLatestAt(),
timings: emptyKnowledgeSearchTimings()
}
}
/**
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
*
* 优先用 per-conversation 的 `source_high_water_time` 聚合(`MAX(...)`):每个**成功处理**
* 的会话都会立刻推进并持久化它(不是等整遍 pass 结束才写),所以增量 pass 同样能让
* freshness 前进。
*
* 为什么这是源侧口径:它是会话最后活跃时间与原始消息 create_time 的最大值,对不可建模的
* 图片/空正文也照算,不是"索引里最新一条可建模消息的时间",所以不会被不可建模消息带偏;
* 并且它在 delta 空读可疑时**不推进**(安全阀在 KnowledgeSearchService 侧),
* 不会把"没读到"谎报成"已覆盖"。
*
* 不用 `source_latest_at` meta 优先:它的写入条件要求「这一遍没有任何跳过」,
* 而增量世界里"有跳过"是常态,于是它会冻结在最后一次全量 pass 的值上,
* 导致 `isKnowledgeFresh()` 恒为 false。
*
* 回退顺序:老库没有该列(或整列为 NULL)→ `source_latest_at` meta →
* `MAX(high_water_time)`(被建模消息的最新时间,只会偏旧,作下限是安全的)。
*/
private readIndexLatestAt(): number | null {
try {
const covered = this.database
.prepare('SELECT MAX(source_high_water_time) AS latest FROM knowledge_index_state')
.get() as DbRow | undefined
const value = Number(covered?.latest)
if (Number.isFinite(value) && value > 0) return value
} catch {
// 老库可能还没有 source_high_water_time 这一列(migration 之前)→ 走回退。
}
const recorded = Number(this.readMeta('source_latest_at'))
if (Number.isFinite(recorded) && recorded > 0) return recorded
try {
const row = this.database
.prepare('SELECT MAX(high_water_time) AS latest FROM knowledge_index_state')
.get() as DbRow | undefined
const value = Number(row?.latest)
return Number.isFinite(value) && value > 0 ? value : null
} catch {
return null
}
}
getRuntimeStatus(): KnowledgeRuntimeStatus {
const search = this.getSearchStatus()
const storage = this.getStorageStats()
@@ -320,7 +369,20 @@ export class KnowledgeStore {
const error = this.readMeta('run_error') || undefined
return {
accountId: this.accountId,
state: runState === 'error' ? 'error' : search.state === 'ready' ? 'ready' : 'unavailable',
// 注意区分三种「不是 ready」:
// - 'error' :这一遍真的失败了;
// - 'cancelled' :用户主动取消(已提交的会话保留、可继续,绝不留一个假的 indexing);
// - 'unavailable':还没有任何可用分片。
// 这里**不**用 `search.state`(那是「能不能查」):取消后派生库仍然可查,
// 但 UI 需要区分「可用 · 已追至最新」与「可用 · 同步已取消」。
state:
runState === 'error'
? 'error'
: runState === 'cancelled'
? 'cancelled'
: search.state === 'ready'
? 'ready'
: 'unavailable',
indexedMessageCount: search.indexedMessageCount,
indexedChunkCount: search.indexedChunkCount,
sourceMessageCount,
@@ -330,7 +392,10 @@ export class KnowledgeStore {
databaseBytes: storage.databaseBytes,
walBytes: storage.walBytes,
shmBytes: storage.shmBytes,
lastError: error
lastError: error,
// sourceLatestAt 属于源数据(WCDB),派生库本身看不到,由 KnowledgeSearchService 补齐。
indexLatestAt: search.indexLatestAt,
sourceLatestAt: null
}
}
@@ -345,6 +410,7 @@ export class KnowledgeStore {
} {
const startedAt = Date.now()
let ftsMs = 0
let shortTermSearchMs = 0
let messageLoadMs = 0
let chunkExpandMs = 0
let rankingMs = 0
@@ -358,6 +424,36 @@ export class KnowledgeStore {
])
).filter(Boolean)
const senderIds = new Set(query.senderIds || [])
if (query.conversationBoundary && conversationIds.length === 1) {
const direction = query.conversationBoundary === 'first' ? 'ASC' : 'DESC'
const row = this.database
.prepare(
`SELECT conversation_id, message_id, create_time, searchable_text, kind, sender_id, sender_name
FROM knowledge_messages
WHERE conversation_id = ? AND kind <> 'system'
ORDER BY create_time ${direction}, message_id ${direction}
LIMIT 1`
)
.get(conversationIds[0]) as DbRow | undefined
const count = this.database
.prepare('SELECT COUNT(*) AS total FROM knowledge_messages WHERE conversation_id = ?')
.get(conversationIds[0]) as DbRow | undefined
const indexState = this.database
.prepare('SELECT state, complete_snapshot FROM knowledge_index_state WHERE conversation_id = ?')
.get(conversationIds[0]) as DbRow | undefined
return {
evidence: row ? [this.metadataEvidence(row)] : [],
conversationRetrieval: {
conversationId: conversationIds[0],
totalMessages: Number(count?.total || 0),
chunkCount: 0,
candidateMessages: row ? 1 : 0,
systemMessagesDeprioritized: 0,
complete: String(indexState?.state || '') === 'ready' && Number(indexState?.complete_snapshot || 0) === 1
},
timings: { ...emptyKnowledgeSearchTimings(), messageLoadMs: Date.now() - startedAt, totalMs: Date.now() - startedAt }
}
}
if (!terms.length) {
const messageLoadStartedAt = Date.now()
const metadata = this.searchByMetadata(query, conversationIds, senderIds)
@@ -475,6 +571,7 @@ export class KnowledgeStore {
timings: {
...emptyKnowledgeSearchTimings(),
ftsMs,
shortTermSearchMs,
messageLoadMs,
chunkExpandMs,
rankingMs,
@@ -668,7 +765,8 @@ export class KnowledgeStore {
evidence: asRows(
this.database
.prepare(
`SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.kind, m.sender_id, m.sender_name
`SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.kind, m.sender_id, m.sender_name,
m.image_ocr_text, m.voice_transcript
FROM knowledge_messages m
WHERE ${clauses.join(' AND ')}
ORDER BY m.create_time DESC
@@ -696,14 +794,31 @@ export class KnowledgeStore {
timestamp: Number(row.create_time),
messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId],
sourceKind: String(row.kind) as KnowledgeEvidence['sourceKind'],
text: String(row.searchable_text),
// 内部前缀(`图片文字:`)绝不能进 Evidence:面向用户与模型的是可读文本,
// 来源信息由下面的结构化字段表达。
text: toEvidenceDisplayText(String(row.searchable_text)),
...(row.image_ocr_text ? { imageOcrText: String(row.image_ocr_text) } : {}),
/*
* 来源标记按"这条消息带什么派生内容"判定,与 `sourceKind` 正交:
* `image_ocr` = 靠图片里的文字命中,`voice_transcript` = 靠语音转写命中。
*
* 两者都有时以图片 OCR 为先 —— 图片消息不会同时带语音转写,这里只是取确定值,
* 实际不会出现需要二选一的数据。
*/
...(row.image_ocr_text
? { derivedSource: 'image_ocr' as const }
: String(row.voice_transcript || '').trim()
? { derivedSource: 'voice_transcript' as const }
: {}),
score: String(row.kind) === 'system' ? 1 : 0
}
}
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
const startedAt = Date.now()
const statusStartedAt = Date.now()
const status = this.getSearchStatus()
const statusMs = Date.now() - statusStartedAt
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
const voiceStartedAt = Date.now()
const voiceCoverage = this.getVoiceCoverage(query)
@@ -720,6 +835,7 @@ export class KnowledgeStore {
totalMs: workerExecutionMs,
globalCountMs: statsRefreshMs,
voiceCoverageMs,
statusMs,
workerExecutionMs
},
conversationRetrieval: measured?.conversationRetrieval,
@@ -803,10 +919,20 @@ export class KnowledgeStore {
attachment_json TEXT,
voice_transcript TEXT,
voice_transcript_state TEXT,
image_ocr_text TEXT,
PRIMARY KEY (conversation_id, message_id)
) STRICT;
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
ON knowledge_messages (conversation_id, create_time);
-- 跨会话 lexical probe 的短词回退路径是「全表 LIKE + ORDER BY create_time DESC LIMIT k」。
-- 没有这个索引时 SQLite 只能 SCAN + TEMP B-TREE,代价随表增长线性上升;
-- 有了它就能按时间倒序走索引并提前终止(同一 ORDER BY / 同一 LIMIT,结果集完全一致),
-- 降到毫秒级。它不改变任何检索语义,只是让同一条 SQL 有可用的访问路径。
CREATE INDEX IF NOT EXISTS knowledge_messages_time
ON knowledge_messages (create_time);
-- 语音覆盖聚合按 (kind, conversation_id[, create_time]) 过滤;没有它就只能全表扫。
CREATE INDEX IF NOT EXISTS knowledge_messages_kind_conversation
ON knowledge_messages (kind, conversation_id);
CREATE TABLE IF NOT EXISTS knowledge_chunks (
rowid INTEGER PRIMARY KEY,
chunk_id TEXT NOT NULL UNIQUE,
@@ -832,10 +958,18 @@ export class KnowledgeStore {
state TEXT NOT NULL,
high_water_time INTEGER,
indexed_message_count INTEGER NOT NULL DEFAULT 0,
complete_snapshot INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
updated_at INTEGER NOT NULL
) STRICT;
`)
const stateColumns = new Set(asRows(this.database.prepare('PRAGMA table_info(knowledge_index_state)').all()).map((row) => String(row.name)))
if (!stateColumns.has('complete_snapshot')) {
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0')
}
if (!stateColumns.has('source_high_water_time')) {
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN source_high_water_time INTEGER')
}
const messageColumns = new Set(
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
String(row.name)
@@ -844,6 +978,14 @@ export class KnowledgeStore {
if (!messageColumns.has('voice_transcript_state')) {
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN voice_transcript_state TEXT')
}
// 图片 OCR 派生文本单独留一列(不只是埋进 searchable_text)。
//
// 为什么必须落列而不是从 searchable_text 里截字符串:Evidence 需要回答
// "这条结果是不是来自图片里的文字",并按此给出来源标记与 OCR 片段。
// 靠解析前缀来判来源,一旦前缀格式调整就会静默失效。
if (!messageColumns.has('image_ocr_text')) {
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN image_ocr_text TEXT')
}
this.writeMetaIfMissing('schema_version', String(KNOWLEDGE_SCHEMA_VERSION))
const storedAccount = this.readMeta('account_id')
if (storedAccount && storedAccount !== this.accountId) {
@@ -924,7 +1066,24 @@ export class KnowledgeStore {
}
}
}
if (changedAt < 0) return { chunkCount: 0, updatedChunks: 0 }
if (changedAt < 0) {
// 内容没有变化 → 不必重建分片。但仍然要记下这一遍扫到的**源侧**边界,
// 否则下一次增量 pass 又会因为缺少标记而重读这个会话(永远无法跳过)。
//
// 单调推进:checkpoint 只应该前进。让一个"看起来更旧"的值覆盖它,会把已经追到最新的
// 会话重新打回"有新消息",于是每一遍都白读一次,还会让 freshness 误判回退。
if (conversation.sourceHighWaterTime !== undefined) {
this.database
.prepare(
`UPDATE knowledge_index_state
SET source_high_water_time = MAX(COALESCE(source_high_water_time, 0), ?),
updated_at = ?
WHERE conversation_id = ?`
)
.run(conversation.sourceHighWaterTime, Date.now(), conversation.conversationId)
}
return { chunkCount: 0, updatedChunks: 0 }
}
const rebuildStart = Math.max(0, changedAt - chunker.overlapMessages)
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
@@ -935,7 +1094,9 @@ export class KnowledgeStore {
chunker.version,
'indexing',
null,
normalized.length
normalized.length,
null,
conversation.completeSnapshot
)
await this.writeMessageLedger(
conversation.conversationId,
@@ -982,7 +1143,10 @@ export class KnowledgeStore {
chunker.version,
'ready',
highWater,
normalized.length
normalized.length,
null,
conversation.completeSnapshot,
conversation.sourceHighWaterTime ?? null
)
this.database.exec('COMMIT')
return { chunkCount: chunks.length, updatedChunks: chunks.length }
@@ -1002,8 +1166,9 @@ export class KnowledgeStore {
const upsert = this.database.prepare(
`INSERT INTO knowledge_messages (
account_id, conversation_id, message_id, create_time, content_hash, searchable_text,
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state,
image_ocr_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
create_time = excluded.create_time,
content_hash = excluded.content_hash,
@@ -1013,7 +1178,8 @@ export class KnowledgeStore {
sender_name = excluded.sender_name,
attachment_json = excluded.attachment_json,
voice_transcript = excluded.voice_transcript,
voice_transcript_state = excluded.voice_transcript_state`
voice_transcript_state = excluded.voice_transcript_state,
image_ocr_text = excluded.image_ocr_text`
)
for (let index = 0; index < messages.length; index += 1) {
this.assertNotAborted(signal)
@@ -1030,7 +1196,8 @@ export class KnowledgeStore {
message.senderName ?? null,
message.attachment ? encodedJson(message.attachment) : null,
message.voiceTranscript ?? null,
message.voiceTranscriptState ?? null
message.voiceTranscriptState ?? null,
message.imageOcrText ?? null
)
if (index % YIELD_EVERY === 0) {
onProgress(index + 1, 0)
@@ -1122,22 +1289,37 @@ export class KnowledgeStore {
state: string,
highWater: number | null,
messageCount: number,
error: string | null = null
error: string | null = null,
completeSnapshot = false,
/**
* 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。
* 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉,
* 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。
*/
sourceHighWater: number | null = null
): void {
this.database
.prepare(
`INSERT INTO knowledge_index_state (
conversation_id, account_id, chunker_version, state, high_water_time,
indexed_message_count, last_error, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET
account_id = excluded.account_id,
chunker_version = excluded.chunker_version,
state = excluded.state,
high_water_time = excluded.high_water_time,
indexed_message_count = excluded.indexed_message_count,
complete_snapshot = excluded.complete_snapshot,
last_error = excluded.last_error,
updated_at = excluded.updated_at`
updated_at = excluded.updated_at,
-- 单调推进 + 保留 NULL 语义:新值为 NULL 时保持旧值;否则取两者较大者。
-- 若退化成写 0,readSourceHighWaterMarks() 的 IS NOT NULL 就会把该会话
-- 当成"有 checkpoint 但等于 0",从而每遍都误走 backfill 全量读。
source_high_water_time = CASE
WHEN excluded.source_high_water_time IS NULL THEN knowledge_index_state.source_high_water_time
ELSE MAX(COALESCE(knowledge_index_state.source_high_water_time, 0), excluded.source_high_water_time)
END`
)
.run(
conversationId,
@@ -1146,11 +1328,33 @@ export class KnowledgeStore {
state,
highWater,
messageCount,
completeSnapshot ? 1 : 0,
error,
Date.now()
Date.now(),
sourceHighWater
)
}
/**
* 每个会话「已经索引到源数据的哪个时刻」(epoch ms)。
*
* 增量 pass 用它判断哪些会话真的需要重新读取:Session 行的 `last_timestamp` 不晚于这个值
* 就说明没有新消息,可以直接跳过(不读 WCDB、不写索引)。
*/
readSourceHighWaterMarks(): Record<string, number> {
const marks: Record<string, number> = {}
for (const row of asRows(
this.database
.prepare(
'SELECT conversation_id, source_high_water_time FROM knowledge_index_state WHERE source_high_water_time IS NOT NULL'
)
.all()
)) {
marks[String(row.conversation_id)] = Number(row.source_high_water_time)
}
return marks
}
private readMeta(key: string): string | null {
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
| DbRow
@@ -1184,6 +1388,43 @@ export class KnowledgeStore {
this.refreshStatsSnapshot()
}
/**
* 搜索热路径专用的快照读取(**不做全表聚合**)。
*
* 分开的原因:`markStatsStale()` 在**每次** `index()` 调用时都会执行,而
* `KnowledgeSearchService.indexAccount` 是**逐会话**调用 `index()` 的,所以一遍后台 pass
* 进行中 `stats_state` 几乎永远是 `'stale'`,pass 被中断后更是会一直留在 `'stale'`。
* 如果每次搜索都因此重做三次全表聚合(messages / chunks / voice),单个 probe 的代价就是
* 数十秒级,交互查询会被卡住。
*
* 规则:只有在快照**从未建立**(首库)或**明显与真实数据不符**(快照说 0 个分片、
* 但库里确实有分片 → 会把可查询的库误报成 unavailable)时才刷新。其余情况沿用上一次完整
* pass 写下的结论:数字可能略旧,但不会把可查询的库说成不可用,也不会把交互查询卡在
* 全表聚合上。
*/
private ensureStatsSnapshotForQuery(): void {
const state = this.readMeta('stats_state')
if (state === 'fresh') return
if (state === null) {
this.refreshStatsSnapshot()
return
}
if (this.readStatNumber('stats_chunk_count') === 0 && this.hasAnyChunk()) {
this.refreshStatsSnapshot()
}
}
/** 只探一行,用于避免把"有分片但快照过期"的库报成不可用。 */
private hasAnyChunk(): boolean {
try {
return Boolean(
this.database.prepare('SELECT 1 AS present FROM knowledge_chunks LIMIT 1').get()
)
} catch {
return false
}
}
private markStatsStale(): void {
this.writeMeta('stats_state', 'stale')
}
+31 -2
View File
@@ -19,6 +19,7 @@ type WorkerResult =
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| { marks: Record<string, number> }
| { removed: true }
type PendingRequest = {
resolve: (result: WorkerResult) => void
@@ -36,6 +37,14 @@ export class KnowledgeWorkerHost {
private child: ChildProcess | null = null
private childStartedAt = 0
private readonly pending = new Map<string, PendingRequest>()
/**
* 当前在跑的索引请求 id。
*
* 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 ——
* 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能
* 精确地只中止索引,而**不会**影响任何并发进行的查询请求。
*/
private activeIndexRequestId: string | null = null
constructor(private readonly workerPath: string) {}
@@ -43,7 +52,9 @@ export class KnowledgeWorkerHost {
payload: KnowledgeIndexRequest,
onProgress?: (progress: KnowledgeIndexProgress) => void
): Promise<KnowledgeIndexResult> {
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult>
return this.request('index', payload, onProgress, (requestId) => {
this.activeIndexRequestId = requestId
}) as Promise<KnowledgeIndexResult>
}
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
@@ -58,6 +69,21 @@ export class KnowledgeWorkerHost {
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
}
/** 每个会话已经索引到的源侧时刻;用于增量 pass 跳过没有变化的会话。 */
highWaterMarks(payload: KnowledgeStatusRequest): Promise<Record<string, number>> {
return this.request('highWater', payload as unknown as KnowledgeWorkerRequest['payload']).then(
(result) => ('marks' in result ? result.marks : {})
)
}
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
async cancelActiveIndex(): Promise<boolean> {
const target = this.activeIndexRequestId
if (!target) return false
await this.cancel(target)
return true
}
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
}
@@ -81,13 +107,15 @@ export class KnowledgeWorkerHost {
private request(
type: KnowledgeWorkerRequest['type'],
payload: KnowledgeWorkerRequest['payload'],
onProgress?: (progress: KnowledgeIndexProgress) => void
onProgress?: (progress: KnowledgeIndexProgress) => void,
onRequestId?: (requestId: string) => void
): Promise<WorkerResult> {
const hadWorker = Boolean(this.child?.connected)
const child = this.ensureChild()
const requestId = randomUUID()
const sentAt = Date.now()
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
onRequestId?.(requestId)
return new Promise((resolve, reject) => {
this.pending.set(requestId, {
resolve,
@@ -145,6 +173,7 @@ export class KnowledgeWorkerHost {
const pending = this.pending.get(requestId)
if (!pending) return
this.pending.delete(requestId)
if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null
if (error) pending.reject(error)
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
else pending.reject(new Error('Knowledge worker returned no result'))
+26 -1
View File
@@ -114,6 +114,7 @@ async function handleSearch(
evidence: [],
indexedMessageCount: 0,
indexedChunkCount: 0,
indexLatestAt: null,
timings: emptyKnowledgeSearchTimings()
},
workerReceivedAt,
@@ -153,7 +154,9 @@ async function handleStatus(
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null
}
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return
@@ -166,6 +169,24 @@ async function handleStatus(
})
}
/**
* 每个会话「已经索引到源数据的哪个时刻」。
* 增量 pass 靠它决定哪些会话可以整段跳过(见 `KnowledgeStore.readSourceHighWaterMarks`)。
*/
async function handleHighWater(
request: KnowledgeWorkerRequest,
payload: KnowledgeStatusRequest
): Promise<void> {
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
const marks = existsSync(path) ? getStore(payload).readSourceHighWaterMarks() : {}
send({
version: 1,
type: 'result',
requestId: request.requestId,
payload: { marks } as unknown as KnowledgeWorkerResponse['payload']
})
}
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
try {
if (request.type === 'cancel') {
@@ -201,6 +222,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleStatus(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'highWater') {
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest)
return
+24
View File
@@ -0,0 +1,24 @@
/**
* 消息身份的**唯一真源**。
*
* 这个规则同时被三处需要:
* - Knowledge 索引写入 `knowledge_messages.message_id`
* - 图片文字索引的 binding(必须与 Knowledge 里的 message_id 完全一致,否则 OCR 文本贴不到消息上)
* - Evidence → 档案跳转的 messageRef
*
* 任何一处各自复制一份,都会在 `local:` 前缀上静默失配(项目里已经有这个坑的历史注释),
* 所以抽成一个模块,谁都不许再抄。
*/
import type * as chat from '../services/chat-service'
/**
* 源消息 → 稳定消息 id。
*
* 降级顺序刻意保守:`localId` 是 WCDB 行内最稳的本地 id;其次用消息自带 id;
* 最后才退化成「时间 + 服务端 id / 内容」的组合(仅在极端缺字段时命中)。
*/
export function sourceMessageId(message: chat.FormattedMessage): string {
if (message.localId) return `local:${message.localId}`
if (message.id) return String(message.id)
return `${message.createTime || 0}:${message.serverId || message.content}`
}
+5
View File
@@ -24,6 +24,10 @@ export function normalizeKnowledgeMessage(
const transcript = compact(source.voiceTranscript)
if (transcript) sections.push(`语音转写:${transcript}`)
// 图片 OCR 文本:与语音同样的"固定前缀"约定,让检索与展示都能识别这是派生内容。
const imageText = compact(source.imageOcrText)
if (imageText) sections.push(`图片文字:${imageText}`)
const attachmentName = compact(source.attachment?.name)
if (attachmentName) {
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
@@ -45,6 +49,7 @@ export function normalizeKnowledgeMessage(
senderId: source.senderId || '',
kind: source.kind,
voiceTranscriptState: source.voiceTranscriptState || '',
imageOcrState: source.imageOcrState || '',
searchableText
})
)

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