mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
Compare commits
108
Commits
nanin/develop
...
v2.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c6e923bc | ||
|
|
afb876250e | ||
|
|
19bf497af0 | ||
|
|
784bed0ee0 | ||
|
|
52c56bfc98 | ||
|
|
e69c3626d6 | ||
|
|
7864cf001f | ||
|
|
264d25b539 | ||
|
|
62c1b0063f | ||
|
|
4aef093802 | ||
|
|
88ee3f19bb | ||
|
|
8503c92f3a | ||
|
|
95b3ba6121 | ||
|
|
b6d287a299 | ||
|
|
4c25236070 | ||
|
|
c1224cb612 | ||
|
|
5036639aa1 | ||
|
|
9593ca0f54 | ||
|
|
b441287d54 | ||
|
|
9b82d29037 | ||
|
|
0f270366aa | ||
|
|
12b8fb34c6 | ||
|
|
774346d503 | ||
|
|
b97e1f4bc6 | ||
|
|
4b2395a8d2 | ||
|
|
1337bcb7de | ||
|
|
b8f08d54c8 | ||
|
|
24399f1d70 | ||
|
|
e6db4de711 | ||
|
|
adff6021fc | ||
|
|
75004375df | ||
|
|
75de616527 | ||
|
|
f5158fd3e4 | ||
|
|
ed4c21a68f | ||
|
|
f35ca1778a | ||
|
|
21b2b57d28 | ||
|
|
67dc45819e | ||
|
|
ff212d594c | ||
|
|
353df30ff8 | ||
|
|
4f8435d0e2 | ||
|
|
389a853560 | ||
|
|
c437be349e | ||
|
|
488966d771 | ||
|
|
00095ae627 | ||
|
|
ffbe78f74a | ||
|
|
7e9a79486e | ||
|
|
4191d4d5f5 | ||
|
|
29c1166eca | ||
|
|
bf78055b69 | ||
|
|
550e3b104c | ||
|
|
cf9ea8a83b | ||
|
|
5efae86fcf | ||
|
|
70c55442f3 | ||
|
|
9288256a5e | ||
|
|
e5bd162a01 | ||
|
|
cef1ad493c | ||
|
|
add16885fe | ||
|
|
ab3b3731b7 | ||
|
|
0c4932d740 | ||
|
|
fe8619d56d | ||
|
|
376ea79ff6 | ||
|
|
dc6d1eb6ac | ||
|
|
532e135406 | ||
|
|
7ae0d63a7f | ||
|
|
c759bcd30e | ||
|
|
0bad419150 | ||
|
|
facc292443 | ||
|
|
7554de7d72 | ||
|
|
1bc66e33fd | ||
|
|
976aac32c5 | ||
|
|
3634f49a48 | ||
|
|
1156362c3c | ||
|
|
60528ffe53 | ||
|
|
f28480ffbe | ||
|
|
8e784d8125 | ||
|
|
3489535750 | ||
|
|
5e268cd8f4 | ||
|
|
454f449c41 | ||
|
|
517e650942 | ||
|
|
21e299942b | ||
|
|
bde3bc537f | ||
|
|
719ffa0a24 | ||
|
|
8fa4dd50e7 | ||
|
|
3462d49516 | ||
|
|
7ef991aa67 | ||
|
|
4d758b2aca | ||
|
|
27f4108cf9 | ||
|
|
4075e52eec | ||
|
|
f7d619292b | ||
|
|
e1f4ec79dd | ||
|
|
76e9e683c4 | ||
|
|
7c8b45a24a | ||
|
|
1c8bbae1f0 | ||
|
|
25b72c3d76 | ||
|
|
f65dc4ac32 | ||
|
|
e59332b514 | ||
|
|
6cbf9af662 | ||
|
|
5acfa14475 | ||
|
|
698251796f | ||
|
|
a007e110ac | ||
|
|
7cf043e3c3 | ||
|
|
949e6bc868 | ||
|
|
62abd872ee | ||
|
|
0d68217dda | ||
|
|
ee5f50db83 | ||
|
|
b26d4dd1a7 | ||
|
|
8f9a0c44da | ||
|
|
5ff67edaef |
@@ -5,6 +5,9 @@ VITE_DB_KEY=
|
|||||||
# Set to true/1/yes/on for local development. Default is disabled.
|
# Set to true/1/yes/on for local development. Default is disabled.
|
||||||
VITE_AUTO_LOGIN=false
|
VITE_AUTO_LOGIN=false
|
||||||
|
|
||||||
|
# Show the scheduled report debug notification test action. Default is disabled.
|
||||||
|
VITE_SCHEDULED_REPORT_DEBUG=false
|
||||||
|
|
||||||
# AI API Configuration (Optional, can be entered in UI)
|
# AI API Configuration (Optional, can be entered in UI)
|
||||||
# 注意:发布版本不再自动读取以下环境变量。
|
# 注意:发布版本不再自动读取以下环境变量。
|
||||||
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||||
|
|||||||
@@ -25,16 +25,10 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
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
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
- name: Install Playwright Chromium
|
||||||
if: runner.os == 'macOS'
|
|
||||||
run: pnpm exec playwright install chromium
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
@@ -52,9 +46,6 @@ jobs:
|
|||||||
- name: Skill installation instruction tests
|
- name: Skill installation instruction tests
|
||||||
run: pnpm test:skill-install
|
run: pnpm test:skill-install
|
||||||
|
|
||||||
- name: WeChat connector tests
|
|
||||||
run: pnpm test:wechat-connector
|
|
||||||
|
|
||||||
- name: Build Electron test application
|
- name: Build Electron test application
|
||||||
run: pnpm test:e2e:build
|
run: pnpm test:e2e:build
|
||||||
|
|
||||||
@@ -64,6 +55,8 @@ jobs:
|
|||||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||||
|
|
||||||
- name: Platform visual regression
|
- name: Platform visual regression
|
||||||
|
# GitHub-hosted Windows 无法稳定容纳项目统一的 1400×800 Electron 窗口,Windows visual regression 改由可控 Windows 桌面环境执行。
|
||||||
|
if: matrix.os != 'windows-latest'
|
||||||
run: pnpm exec playwright test tests/e2e/visual.spec.ts
|
run: pnpm exec playwright test tests/e2e/visual.spec.ts
|
||||||
env:
|
env:
|
||||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||||
|
|||||||
+9
-9
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
electron.vite.config.[0-9]*.mjs
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
.env
|
.env
|
||||||
@@ -9,19 +10,18 @@ out
|
|||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
resources/connectors/wechat/
|
|
||||||
resources/connectors/wechat-personal/
|
resources/connectors/wechat-personal/
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
services/share-card-worker/.wrangler/
|
|
||||||
services/share-card-worker/wrangler.local.jsonc
|
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
docs/design/
|
*__screenshots__
|
||||||
docs/ui-redesign-plan.md
|
|
||||||
docs/ui-redesign-spec.md
|
/AGENTS.md
|
||||||
AGENTS.md
|
/CLAUDE.md
|
||||||
|
/.claude/
|
||||||
|
/.workbuddy/
|
||||||
|
/.ai-local/
|
||||||
|
|
||||||
findings.md
|
findings.md
|
||||||
progress.md
|
progress.md
|
||||||
task_plan.md
|
task_plan.md
|
||||||
.agents
|
|
||||||
*__screenshots__
|
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# 参与贡献
|
||||||
|
|
||||||
|
> 这份文档同时写给人和 AI Agent。文末有给 Agent 的英文硬性规则。
|
||||||
|
> 如果你只是想把 TraceMemo 跑起来,看[第一次使用](./docs/user-guide/getting-started.md)就够了。
|
||||||
|
|
||||||
|
## 一句话规则
|
||||||
|
|
||||||
|
**从 `develop` 拉分支,把 PR 提给 `develop`。**
|
||||||
|
|
||||||
|
## 为什么不是 main
|
||||||
|
|
||||||
|
| 分支 | 是什么 | 接受 PR 吗 |
|
||||||
|
| --------- | ------------------------------------------------------- | -------------- |
|
||||||
|
| `main` | 稳定版。只在发版时更新,对应 GitHub Releases 里的安装包 | 不接受 |
|
||||||
|
| `develop` | 开发主线。所有改动先进这里,**随下一个版本一起发布** | 唯一的目标分支 |
|
||||||
|
|
||||||
|
指向 `main` 的 PR、或者不是基于 `develop` 拉出来的分支,会被直接关闭。不是不欢迎贡献,而是因为冲突合并 提交落后较多等原因
|
||||||
|
|
||||||
|
## 提 PR 的完整流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 基于 develop 拉分支(不要基于 main)
|
||||||
|
git fetch origin
|
||||||
|
git checkout -b feat/your-change origin/develop
|
||||||
|
|
||||||
|
# 2. 改代码,只改与本任务相关的文件
|
||||||
|
|
||||||
|
# 3. 自检
|
||||||
|
pnpm install # 需要 Node 22 与 pnpm 7.33.7
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test:unit # 再按改动范围补跑 component / integration
|
||||||
|
|
||||||
|
# 4. 提交
|
||||||
|
git commit -m "feat: 一句话说明改了什么"
|
||||||
|
|
||||||
|
# 5. 提 PR,目标分支必须是 develop
|
||||||
|
gh pr create --base develop --head feat/your-change
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提交信息
|
||||||
|
|
||||||
|
默认**只写一行标题**:类型前缀 + 一句话说明。
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: 新增本地查询能力
|
||||||
|
fix: 修复查询工具时间契约
|
||||||
|
docs: 整理开发文档
|
||||||
|
```
|
||||||
|
|
||||||
|
确实包含多个功能点时,标题之后每个功能点各写一行纯文本 —— 不要用列表符号,也不要写
|
||||||
|
「设置侧:」「测试:」这类分节标题:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: 统一手动发送入口为文字转语音
|
||||||
|
移除普通文本、图片和本地语音的手动发送入口
|
||||||
|
保留文字转语音的生成、试听和发送能力
|
||||||
|
```
|
||||||
|
|
||||||
|
不要在提交信息里堆文件名清单、测试结果或实现过程叙述,那些属于 PR 描述。
|
||||||
|
|
||||||
|
## 分支命名
|
||||||
|
|
||||||
|
`feat/…`、`fix/…`、`docs/…`、`refactor/…`,后面接简短的英文或拼音描述。
|
||||||
|
|
||||||
|
## PR 前自检
|
||||||
|
|
||||||
|
| 你的改动 | 至少跑这些 |
|
||||||
|
| -------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| 一般代码 / 服务 / 工具函数 | `pnpm typecheck` + 相关 `pnpm test:unit` |
|
||||||
|
| 界面 / 交互 | `pnpm typecheck` + 相关 `pnpm test:component`,或针对该功能的 E2E |
|
||||||
|
| preload / IPC 契约 | `pnpm typecheck` + 相关 contract 测试或 `pnpm test:integration` |
|
||||||
|
| 只改文档 | 不需要跑测试 |
|
||||||
|
|
||||||
|
全部测试命令见 `package.json` 的 `scripts`;本地开发环境的说明见[开发概览](./docs/development/overview.md)。
|
||||||
|
|
||||||
|
## 请不要提交这些东西
|
||||||
|
|
||||||
|
- **构建产物**:`out/`、`dist/`、`build/` 下的二进制、`node_modules/`、`test-results/`、`playwright-report/`
|
||||||
|
- **真实数据**:微信聊天内容、真实的 wxid / 群名 / 联系人名、聊天截图
|
||||||
|
- **密钥**:API Key、Token、数据库密钥、图片解密密钥
|
||||||
|
- **本机绝对路径**:`/Users/…`、`C:\Users\…`
|
||||||
|
|
||||||
|
测试用的假数据请用一眼能看出是合成的命名,例如 `fixture-group`、`wxid_fixture_member`。
|
||||||
|
|
||||||
|
## 合入之后
|
||||||
|
|
||||||
|
PR 合进 `develop` 不会立刻出现在下载页,它随**下一个版本**发布。想提前用上,可以自行从
|
||||||
|
`develop` 构建。
|
||||||
|
|
||||||
|
## 如果你是 AI Agent
|
||||||
|
|
||||||
|
请按顺序执行,不要凭直觉选分支:
|
||||||
|
|
||||||
|
1. **基线**:`git fetch origin && git checkout -b <branch> origin/develop`。永远不要基于 `main`。
|
||||||
|
2. **PR 目标**:`gh pr create --base develop`。无法确定时默认 `develop`;任何情况下都不要把
|
||||||
|
`main` 当 PR 目标。
|
||||||
|
3. **改动范围**:只改与本任务相关的文件。不要顺手格式化、重排 import、升级依赖。
|
||||||
|
4. **提交前**:跑 `pnpm typecheck` 和与本次改动相关的测试(见上表)。
|
||||||
|
5. **提交信息**:一行标题,`type: 描述`。不要写文件清单、测试输出或过程叙述。
|
||||||
|
6. **PR 描述**:说明改了什么、为什么改、怎么验证的;关联 Issue 用 `closes #123`。
|
||||||
|
7. **禁止**:真实聊天数据、密钥、Token、本机绝对路径、构建产物。
|
||||||
|
|
||||||
|
### Hard rules for AI agents (English)
|
||||||
|
|
||||||
|
- Base branch: `origin/develop`. Never branch off `main`.
|
||||||
|
- Open pull requests with base branch `develop`. PRs targeting `main` are closed without review.
|
||||||
|
- One PR = one logical change. No drive-by reformatting, import reordering, or dependency upgrades.
|
||||||
|
- Before opening a PR, run `pnpm typecheck` plus the tests relevant to your change.
|
||||||
|
- Commit subject: a single line, `type: summary`. No file lists, no test logs, no process narration.
|
||||||
|
- Never commit build output (`out/`, `dist/`, `test-results/`, `playwright-report/`), real WeChat
|
||||||
|
data, keys, tokens, or absolute local paths.
|
||||||
|
- Changes merged into `develop` ship with the next release.
|
||||||
@@ -4,12 +4,9 @@
|
|||||||
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
|
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
<h2 align="center">把微信里的信息,记住、理解、监控,并在需要时行动</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">本地优先的微信数据、AI 分析与自动化工作台</p>
|
||||||
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
|
|
||||||
查看聊天 · 找回信息 · AI 问答 · 微信群聊总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/TraceMemo?style=for-the-badge" alt="GitHub stars" />
|
<img src="https://img.shields.io/github/stars/Wxw-Gu/TraceMemo?style=for-the-badge" alt="GitHub stars" />
|
||||||
@@ -23,60 +20,71 @@
|
|||||||
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||||
·
|
·
|
||||||
<a href="./docs/README.md"><b>完整文档</b></a>
|
<a href="./docs/README.md"><b>完整文档</b></a>
|
||||||
|
·
|
||||||
|
<a href="./docs/concepts/how-it-works.md"><b>TraceMemo 如何工作</b></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
|
<img src="./public/日报.png" alt="TraceMemo 主界面" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" />
|
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
|
||||||
|
</p>
|
||||||
---
|
---
|
||||||
|
|
||||||
## TraceMemo(迹忆)是什么
|
## 🎨 社区日报模板
|
||||||
|
|
||||||
TraceMemo(迹忆)是一款**本地优先、可追溯的 AI 微信知识与分析工作台**。
|
TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多样式。社区模板与默认日报读取同一份真实日报数据,只改变展示方式,适合手机长图分享、桌面归档、团队复盘等不同场景。
|
||||||
|
|
||||||
TraceMemo 原名 **WechatExplorer**,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
|
<p align="center">
|
||||||
|
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates"><b>浏览 TraceMemo 模板社区</b></a>
|
||||||
|
·
|
||||||
|
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor"><b>用 AI 制作并投稿模板</b></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
|
在 TraceMemo 中打开:
|
||||||
|
|
||||||
你可以直接浏览聊天,也可以用自然语言提问:
|
**日报 → 今日日报 → 日报模板 → 模板市场**
|
||||||
|
|
||||||
> “上个月我们讨论过哪些发布问题?”
|
即可查看、预览、安装和切换已发布的社区模板。
|
||||||
>
|
|
||||||
> “张三之前发过的项目地址在哪里?”
|
|
||||||
>
|
|
||||||
> “技术交流群今天有哪些结论和待办?”
|
|
||||||
|
|
||||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。
|
如果你有一张喜欢的日报长图、网页或前端项目,也可以把它交给 Codex、ChatGPT 或其他能够读取 GitHub 仓库的 AI,并让它读取 [TraceMemo Template Contributor Skill](https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor)。AI 可以帮助你完成模板转换、真实预览,并在你确认满意后向 [TraceMemo-Templates](https://github.com/Wxw-Gu/TraceMemo-Templates) 提交 Pull Request。
|
||||||
|
|
||||||
你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
模板通过审核并正式发布后,其他 TraceMemo 用户即可在模板市场中安装使用。
|
||||||
|
|
||||||
TraceMemo 不提供任何微信聊天数据,也不鼓励收集、上传、出售、共享或未经授权处理他人的聊天记录。使用 TraceMemo 时,请确保你对所处理的数据具有合法的访问和使用权限,并自行承担相应的数据安全与合规责任。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 为什么叫 TraceMemo(迹忆)
|
## TraceMemo 是什么
|
||||||
|
|
||||||
<details>
|
TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数据、AI 分析与自动化工作台,把聊天变成可浏览、可搜索、可理解、可追溯的信息。
|
||||||
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
|
|
||||||
|
|
||||||
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
|
先用档案找原话,再按需要使用 AI Search、日报、监控或 Agent。普通浏览、搜索和导出不需要 AI。
|
||||||
|
|
||||||
“迹忆”可以理解为“留下痕迹的记忆”。
|
## 核心能力
|
||||||
|
|
||||||
TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
|
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
|
||||||
|
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
|
||||||
|
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
|
||||||
|
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
|
||||||
|
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
|
||||||
|
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
|
||||||
|
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
|
||||||
|
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
|
||||||
|
|
||||||
> **品牌说明**
|
## 💻 平台支持
|
||||||
>
|
|
||||||
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
|
|
||||||
|
|
||||||
</details>
|
TraceMemo 2.4.0 支持:
|
||||||
|
|
||||||
---
|
- **Windows x64**
|
||||||
|
- **macOS Apple Silicon(M 系列 / arm64)**
|
||||||
|
- **macOS Intel(x64)**
|
||||||
|
|
||||||
|
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
|
||||||
|
|
||||||
## 项目缘起
|
## 项目缘起
|
||||||
|
|
||||||
@@ -88,11 +96,11 @@ TraceMemo 最早叫 **WechatExplorer**。
|
|||||||
|
|
||||||
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
|
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
|
||||||
|
|
||||||
为了支持微信 4.x,我开始重新研究数据访问。这部分工作得到了 **WeFlow** 很大的帮助。TraceMemo 目前的微信 4.x 数据连接能力,参考并使用了 **WeFlow 历史版本中的相关实现和思路**,包括数据库密钥获取、图片解密等底层能力。
|
为了支持微信 4.x,我开始重新研究数据访问。这部分工作最初得到了 **WeFlow** 很大的帮助。早期 TraceMemo 曾参考 WeFlow 历史版本中的实现和思路,借此解决了数据库消息、密钥获取等微信 4.x 数据访问问题。
|
||||||
|
|
||||||
> **没有 WeFlow,就没有今天的 TraceMemo。**
|
随着项目继续发展,我逐步把这部分底层能力从原有实现中抽离,并重新实现了一套独立的数据访问兼容层。目前会继续保持与 WeFlow 历史接口和行为的兼容,以减少上层业务迁移成本。
|
||||||
|
|
||||||
WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继续做后面的事情:让聊天记录可以被搜索、理解和总结,也让 AI 给出的答案能够回到原始消息核对。
|
也就是说,**WeFlow 是 TraceMemo 进入微信 数据访问领域的重要起点。没有 WeFlow,就没有今天的 TraceMemo。**
|
||||||
|
|
||||||
在此基础上,项目陆续加入了:
|
在此基础上,项目陆续加入了:
|
||||||
|
|
||||||
@@ -105,12 +113,15 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
|
|||||||
- Reader Skill
|
- Reader Skill
|
||||||
- Agent 接入
|
- Agent 接入
|
||||||
- 多种聊天记录导出能力
|
- 多种聊天记录导出能力
|
||||||
|
- 退群监控
|
||||||
|
- 文字转语音
|
||||||
|
- 持续监控自动化能力
|
||||||
|
|
||||||
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
|
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
|
||||||
|
|
||||||
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
|
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
|
||||||
|
|
||||||
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。
|
感谢每一位使用、关注和反馈过的人。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -124,298 +135,54 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
|
|||||||
|
|
||||||
## 从你的任务开始
|
## 从你的任务开始
|
||||||
|
|
||||||
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
| 想做什么 | 使用入口 |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
|
| ---------------------------------- | ----------------------------- |
|
||||||
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
|
| 找记得原文或关键词的消息 | 档案搜索 |
|
||||||
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
|
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
|
||||||
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
|
| 长期跨群查询历史 | 本地知识库 |
|
||||||
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
|
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
|
||||||
| 把群聊日报生成微信分享卡片(实验性) | [微信分享卡片](./docs/deployment/experimental-wechat-share-card.md) | 自备 Cloudflare、域名和微信测试号 |
|
| 持续关注群成员退出 | 退群监控 |
|
||||||
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
|
| 按计划生成并发送群聊日报 | 定时日报 |
|
||||||
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
|
| 把文字生成微信语音 | 文字转语音 |
|
||||||
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
|
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
|
||||||
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
|
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
|
||||||
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
|
| 把聊天保存成文件 | 导出 |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 最核心的三个能力
|
|
||||||
|
|
||||||
### 生成群聊日报
|
|
||||||
|
|
||||||
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。
|
|
||||||
|
|
||||||
报告包含:
|
|
||||||
|
|
||||||
- 热点
|
|
||||||
- 重要消息
|
|
||||||
- 资源
|
|
||||||
- 问答
|
|
||||||
- 待办
|
|
||||||
- 未解决事项
|
|
||||||
- 活跃统计
|
|
||||||
- 图片精选
|
|
||||||
|
|
||||||
具体内容取决于消息、媒体是否可读以及模型能力。
|
|
||||||
详细说明:[生成群聊日报](./docs/user-guide/report.md)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### AI 帮你找回聊过的内容
|
|
||||||
|
|
||||||
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
|
|
||||||
|
|
||||||
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。
|
|
||||||
|
|
||||||
你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
详细说明:[使用 AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
|
||||||
|
|
||||||
### 直接在微信里问你的历史聊天
|
|
||||||
|
|
||||||
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。
|
|
||||||
|
|
||||||
例如,你可以直接给机器人发送:
|
|
||||||
|
|
||||||
- “最近 5 个会话”
|
|
||||||
- “张三最近和我聊了什么”
|
|
||||||
- “总结今天的技术交流群”
|
|
||||||
|
|
||||||
TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
|
|
||||||
|
|
||||||
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。
|
|
||||||
|
|
||||||
当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
|
|
||||||
|
|
||||||
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 其他能力
|
|
||||||
|
|
||||||
### 本地知识库
|
|
||||||
|
|
||||||
<details>
|
|
||||||
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。
|
|
||||||
|
|
||||||
它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
|
|
||||||
|
|
||||||
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
|
|
||||||
|
|
||||||
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### 实验性:生成微信分享卡片
|
|
||||||
|
|
||||||
<details>
|
|
||||||
TraceMemo 可以把群聊日报长图上传到你自己部署的 Cloudflare Worker 和 R2,并生成可在微信中分享的临时网页、二维码及卡片信息。
|
|
||||||
|
|
||||||
该功能需要自备 Cloudflare 账号、域名和微信测试号,目前不属于开箱即用的稳定功能。
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享效果示例" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
详细说明:[实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)。
|
|
||||||
|
|
||||||
不熟悉命令行的用户,可以把[自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)直接交给 Codex 或 Claude Code。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### 转写微信语音
|
|
||||||
|
|
||||||
<details>
|
|
||||||
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。
|
|
||||||
|
|
||||||
转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
|
|
||||||
|
|
||||||
详细说明:[语音转文字](./docs/user-guide/voice.md)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### 导出长期可用的聊天档案
|
|
||||||
|
|
||||||
<details>
|
|
||||||
支持 HTML、CSV、JSON 和 Markdown。
|
|
||||||
|
|
||||||
HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
|
|
||||||
|
|
||||||
详细说明:[导出聊天](./docs/user-guide/export.md)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### 在外部 Agent 中查询微信历史
|
|
||||||
|
|
||||||
<details>
|
|
||||||
通过 Reader Skill 和本机 Local HTTP API,Codex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。
|
|
||||||
|
|
||||||
这和微信机器人是两条不同路径:
|
|
||||||
|
|
||||||
- **微信机器人**:收到消息后在微信中回复。
|
|
||||||
- **外部 Agent**:主动查询历史。
|
|
||||||
|
|
||||||
安装和技术说明请看[Agent 接入概览](./docs/agent/overview.md)与[Local HTTP API](./docs/agent/api.md)。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 它如何工作
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A[本机微信数据] --> B[TraceMemo 读取与解析]
|
|
||||||
B --> C[聊天档案]
|
|
||||||
B --> D[本地知识库与搜索]
|
|
||||||
D --> E[筛选相关聊天来源]
|
|
||||||
E --> F[用户配置的 AI 模型]
|
|
||||||
F --> G[带来源的回答]
|
|
||||||
B --> H[整理日报输入]
|
|
||||||
H --> F
|
|
||||||
B --> I[聊天导出]
|
|
||||||
B --> J[Local HTTP API]
|
|
||||||
J --> K[外部 Agent]
|
|
||||||
L[微信机器人消息] --> M[Agent Hub]
|
|
||||||
M --> B
|
|
||||||
M --> F
|
|
||||||
```
|
|
||||||
|
|
||||||
- 微信数据库读取、聊天解析、知识库索引和离线语音识别在本机完成。
|
|
||||||
- 普通浏览、普通搜索和导出不要求配置 AI 服务。
|
|
||||||
- 使用“问问微信”、群聊日报或图片理解等 AI 功能时,完成任务所需的内容可能发送到你选择的模型服务;具体发送范围和确认方式以对应功能页面为准。
|
|
||||||
- “问问微信”会先在本机缩小范围,不会默认把整个微信数据库作为一次模型请求发送。
|
|
||||||
|
|
||||||
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 支持平台与安装包
|
|
||||||
|
|
||||||
| 平台 | 处理器架构 | Releases 安装包 |
|
|
||||||
| ------- | ------------------------------ | --------------- |
|
|
||||||
| Windows | x64 | `-setup.exe` |
|
|
||||||
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
|
|
||||||
|
|
||||||
当前版本不支持 Intel 芯片的 Mac。
|
|
||||||
|
|
||||||
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载安装包。
|
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载对应平台的安装包。
|
||||||
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
2. 启动应用,按“第一次使用”页面选择微信数据目录并完成连接。
|
||||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
3. 打开“档案”,确认联系人和消息已加载后开始搜索。
|
||||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
4. 需要 AI 时,在“设置 → AI 模型”添加并测试 Provider。
|
||||||
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
|
|
||||||
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
|
||||||
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
|
|
||||||
|
|
||||||
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
|
详细步骤见[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)。
|
||||||
|
|
||||||
当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
|
|
||||||
|
|
||||||
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。
|
|
||||||
|
|
||||||
迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
|
|
||||||
|
|
||||||
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
|
|
||||||
|
|
||||||
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 配置 AI
|
|
||||||
|
|
||||||
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。
|
|
||||||
|
|
||||||
应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
|
|
||||||
|
|
||||||
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
|
|
||||||
|
|
||||||
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- [文档首页](./docs/README.md)
|
- [用户指南](./docs/README.md#用户指南)
|
||||||
- [第一次使用](./docs/user-guide/getting-started.md)
|
- [AI / Knowledge](./docs/README.md#ai-与知识库)
|
||||||
- [聊天档案与搜索](./docs/user-guide/chat-archive.md)
|
- [Monitor / Automation](./docs/README.md#日报与自动化)
|
||||||
- [AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
- [Agent / API](./docs/README.md#agent--api)
|
||||||
- [本地知识库](./docs/user-guide/knowledge.md)
|
- [开发文档](./docs/development/overview.md)
|
||||||
- [群聊日报](./docs/user-guide/report.md)
|
- [隐私与安全](./docs/user-guide/privacy.md)
|
||||||
- [实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)
|
|
||||||
- [微信分享卡片自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)
|
|
||||||
- [语音转文字](./docs/user-guide/voice.md)
|
|
||||||
- [导出聊天](./docs/user-guide/export.md)
|
|
||||||
- [防撤回](./docs/user-guide/recall-protection.md)
|
|
||||||
- [数据、隐私与安全](./docs/user-guide/privacy.md)
|
|
||||||
- [Agent 接入](./docs/agent/overview.md)
|
|
||||||
- [微信机器人与 Agent Hub](./docs/agent/agent-hub.md)
|
|
||||||
- [Local HTTP API](./docs/agent/api.md)
|
|
||||||
- [开发与测试](./docs/development/overview.md)
|
|
||||||
|
|
||||||
---
|
完整目录由[文档首页](./docs/README.md)维护。
|
||||||
|
|
||||||
## 本地开发
|
## 支持平台
|
||||||
|
|
||||||
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
|
| 平台 | 架构 | 微信连接 | 安装包 |
|
||||||
|
| ------- | ------------------------------ | ------------------------------------- | ------------------------------- |
|
||||||
|
| Windows | x64 | 支持微信 4.x | `tracememo-<version>-setup.exe` |
|
||||||
|
| macOS | Apple Silicon(M 系列、arm64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-arm64.dmg` |
|
||||||
|
| macOS | Intel(x64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-x64.dmg` |
|
||||||
|
|
||||||
```bash
|
## 参与贡献
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
常用检查:
|
稳定版在 `main`,只在发版时更新;所有改动都先进 `develop`,随**下一个版本**一起发布。
|
||||||
|
|
||||||
```bash
|
**提 PR 请基于 `develop` 拉新分支,并把 PR 的目标分支设为 `develop`** —— 指向 `main` 的 PR 会被直接关闭。
|
||||||
pnpm typecheck
|
|
||||||
pnpm test:unit
|
|
||||||
pnpm test:component
|
|
||||||
pnpm test:integration
|
|
||||||
pnpm test:e2e:build
|
|
||||||
```
|
|
||||||
|
|
||||||
完整说明:[开发、测试与构建](./docs/development/overview.md)
|
分支流程、提交信息风格、PR 前自检,以及**给 AI Agent 的硬性规则**,都在[参与贡献指南](./CONTRIBUTING.md)。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 支持与反馈
|
|
||||||
|
|
||||||
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
|
|
||||||
|
|
||||||
提交 Issue 时请提供:
|
|
||||||
|
|
||||||
- 操作系统
|
|
||||||
- 微信版本
|
|
||||||
- TraceMemo 版本
|
|
||||||
- 复现步骤
|
|
||||||
- 已遮挡敏感信息的截图
|
|
||||||
|
|
||||||
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
|
|
||||||
|
|
||||||
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 许可说明
|
|
||||||
|
|
||||||
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件。
|
|
||||||
|
|
||||||
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
|
|
||||||
|
|
||||||
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
|
|
||||||
|
|
||||||
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
@@ -423,18 +190,21 @@ TraceMemo 的诞生离不开开源社区中许多优秀项目的工作。
|
|||||||
|
|
||||||
### 特别感谢 WeFlow
|
### 特别感谢 WeFlow
|
||||||
|
|
||||||
TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库密钥获取、图片解密等底层能力。
|
TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库访问、密钥获取等底层能力。
|
||||||
|
|
||||||
特别感谢作者 **hicccc77** 的理解和包容。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
|
特别感谢作者 **[hicccc77](https://github.com/hicccc77)**。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
|
||||||
|
|
||||||
### 其他参考项目
|
### 其他参考项目
|
||||||
|
|
||||||
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
||||||
- 提供了数据库解析相关思路。
|
- 提供了数据解析相关思路。
|
||||||
|
|
||||||
- **[chatlog](https://github.com/sjzar/chatlog)**
|
- **[chatlog](https://github.com/sjzar/chatlog)**
|
||||||
- 提供了数据处理方面的参考。
|
- 提供了数据处理方面的参考。
|
||||||
|
|
||||||
|
- **[wechat_chatter](https://github.com/yincongcyincong/wechat_chatter)**
|
||||||
|
- 提供了发送方面的参考。
|
||||||
|
|
||||||
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
|
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+49
-42
@@ -1,64 +1,71 @@
|
|||||||
# TraceMemo 文档
|
# TraceMemo 文档
|
||||||
|
|
||||||
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
文档按产品任务和使用场景组织。需要集成或开发时,再看 Agent、概念和开发文档。
|
||||||
|
|
||||||
## 从这里开始
|
## 档案与搜索
|
||||||
|
|
||||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。
|
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
|
||||||
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。
|
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
|
||||||
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。
|
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
|
||||||
|
|
||||||
## 你可以完成的任务
|
## AI 与知识库
|
||||||
|
|
||||||
- [建立本地知识库](./user-guide/knowledge.md)
|
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
|
||||||
- [生成群聊日报和总结](./user-guide/report.md)
|
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
|
||||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
|
||||||
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
|
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
|
||||||
- [语音转文字](./user-guide/voice.md)
|
|
||||||
- [导出聊天档案](./user-guide/export.md)
|
|
||||||
- [防撤回](./user-guide/recall-protection.md)
|
|
||||||
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
|
|
||||||
- [数据、隐私与安全](./user-guide/privacy.md)
|
|
||||||
- [常见问题与排查](./user-guide/troubleshooting.md)
|
|
||||||
|
|
||||||
## 如果你想了解 AI 为什么这样回答
|
## 日报与自动化
|
||||||
|
|
||||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。
|
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
|
||||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。
|
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
|
||||||
|
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
|
||||||
|
|
||||||
## 微信机器人和外部 Agent
|
## Monitor
|
||||||
|
|
||||||
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
|
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
|
||||||
|
|
||||||
| 你想做什么 | 应该看哪里 |
|
## 语音能力
|
||||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
||||||
| 在微信里给机器人发消息,让本机读取数据、生成总结并回复 | [Agent Hub](./agent/agent-hub.md) |
|
|
||||||
| 在 Codex、Claude Code、OpenClaw 等外部 Agent 中主动查询过去的微信数据 | [Reader Skill](./agent/reader-skill.md) + [Local HTTP API](./agent/api.md) |
|
|
||||||
|
|
||||||
### 在微信里提问
|
- [语音转文字](./user-guide/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
|
||||||
|
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
|
||||||
|
|
||||||
打开应用一级导航中的“Agent”,进入“Agent Hub”后扫码登录微信机器人。机器人收到文字消息后,可以查询最近会话、读取联系人聊天、生成群聊总结图片或总结群成员发言,并把结果回复给发消息的人。它需要本地微信数据库已经连接;依赖 AI 的任务还需要配置 AI 服务。
|
## Agent / API
|
||||||
|
|
||||||
- [Agent Hub](./agent/agent-hub.md):连接机器人、查看运行状态和了解实时交互边界。
|
Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP API 让外部 Agent 主动查询历史数据。
|
||||||
|
|
||||||
### 让外部 Agent 查询历史微信
|
- [Agent 接入概览](./agent/overview.md)
|
||||||
|
- [Agent Hub](./agent/agent-hub.md)
|
||||||
|
- [Reader Skill](./agent/reader-skill.md)
|
||||||
|
- [Local HTTP API](./agent/api.md)
|
||||||
|
- [API 安全](./agent/api-security.md)
|
||||||
|
|
||||||
连接 Reader Skill 后,你可以询问:
|
## 导出与隐私
|
||||||
|
|
||||||
> “总结今天技术交流群讨论了什么。”
|
- [导出聊天](./user-guide/export.md):导出 HTML、Markdown、CSV 或 JSON 档案。
|
||||||
> “过去一周有没有人提到这个项目?”
|
- [数据、隐私与安全](./user-guide/privacy.md):本地处理、Provider、媒体和 Token 的数据边界。
|
||||||
|
- [常见问题与排查](./user-guide/troubleshooting.md):按安装、连接、AI、媒体和 Agent 现象排查。
|
||||||
|
|
||||||
- [Agent 接入概览](./agent/overview.md):先选择适合你的接入方式。
|
## 开发文档
|
||||||
- [Reader Skill](./agent/reader-skill.md):安装并让外部 Agent 按需读取聊天。
|
|
||||||
- [Local HTTP API](./agent/api.md):完整端点和请求示例。
|
|
||||||
- [API 安全](./agent/api-security.md):Bearer Token、CORS、轮换和边界。
|
|
||||||
|
|
||||||
## 开发与平台
|
|
||||||
|
|
||||||
- [macOS 数据访问说明](./platform/macos.md)
|
|
||||||
- [开发、测试与构建](./development/overview.md)
|
- [开发、测试与构建](./development/overview.md)
|
||||||
|
- [界面开发规范:按钮与主题色](./development/ui-guidelines.md)
|
||||||
|
- [微信系统消息解析与格式兼容](./development/wechat-system-message-parsing.md)
|
||||||
|
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.md)
|
||||||
- [本地启动排障](./development/local-startup-troubleshooting.md)
|
- [本地启动排障](./development/local-startup-troubleshooting.md)
|
||||||
- [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md)
|
- [macOS 数据访问说明](./platform/macos.md)
|
||||||
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
|
- [关闭 SIP 教程](./mac-disable-sip.md)
|
||||||
|
|
||||||
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
## 实验性功能与第三方
|
||||||
|
|
||||||
|
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||||
|
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
||||||
|
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
|
||||||
|
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
|
||||||
|
|
||||||
|
## 版本说明
|
||||||
|
|
||||||
|
- [v2.2.0 品牌与安全迁移](./agent/release-notes-v2.2.0.md)
|
||||||
|
- [v2.1.9 API 鉴权迁移](./agent/release-notes-v2.1.9.md)
|
||||||
|
|
||||||
|
文档按当前 develop 已实现的能力维护,不在首页固定写死版本号。版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||||
|
|||||||
+88
-4
@@ -36,7 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
|||||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||||
| GET | `/api/v1/media/{messageId}` | 获取图片消息的二进制资源 | 使用 `/chatlog` 返回的图片消息 `id` |
|
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
|
||||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||||
@@ -85,9 +85,9 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
|||||||
- `200`:请求成功;
|
- `200`:请求成功;
|
||||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||||
- `400`:参数或 JSON 请求体无效;
|
- `400`:参数或 JSON 请求体无效;
|
||||||
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片;
|
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
|
||||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||||
- `404`:端点、会话或群聊不存在;
|
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
|
||||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||||
- `500`:服务端处理或报告渲染失败。
|
- `500`:服务端处理或报告渲染失败。
|
||||||
|
|
||||||
@@ -102,13 +102,97 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
|||||||
"media": {
|
"media": {
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"available": true,
|
"available": true,
|
||||||
"url": "/api/v1/media/msg_xxx"
|
"url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
|
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
|
||||||
|
|
||||||
|
`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。
|
||||||
|
|
||||||
## 与 MCP 的关系
|
## 与 MCP 的关系
|
||||||
|
|
||||||
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
||||||
|
|
||||||
|
## LLM-friendly Query Tool API
|
||||||
|
|
||||||
|
这些端点提供稳定的结构化 Query primitive,不接收自然语言问题,也不会调用 AI。它们与现有 API 共用端口、Bearer Token、loopback 和 CORS 安全策略。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="http://127.0.0.1:6131/api/v1"
|
||||||
|
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
|
||||||
|
|
||||||
|
# 能力目录
|
||||||
|
curl -H "$AUTH" "$BASE/query/capabilities"
|
||||||
|
|
||||||
|
# BOBO 的第一条真实互动
|
||||||
|
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
|
||||||
|
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"direction":"any","order":"asc","limit":1,"excludeSystem":true}'
|
||||||
|
|
||||||
|
# 上个月 BOBO 发来的文件
|
||||||
|
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
|
||||||
|
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"},"direction":"from_target","messageTypes":["file"],"order":"desc","limit":1}'
|
||||||
|
|
||||||
|
# 受限语义关键词检索(最多 4 个 variants)
|
||||||
|
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/search" \
|
||||||
|
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"query":"答应之后给我或者帮我完成某件事情","variants":["我给你","我发你","弄好给你"],"limit":20}'
|
||||||
|
|
||||||
|
# 按会话和时间范围提取可供总结的证据
|
||||||
|
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/conversation-overview" \
|
||||||
|
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`query/messages` 的 `messageRef` 是服务端生成的不透明引用,可直接传给 `query/message-context` 获取前后文;不要自行构造 wxid、md5 或数据库路径。
|
||||||
|
|
||||||
|
每条消息都会返回 `messageType`(`text`、`image`、`voice`、`video`、`file`、`link`、`sticker`、`system` 或 `other`)。非文本消息不会伪造 `text`;可识别的图片、视频、贴纸和文件会返回不含密钥或本地路径的 `attachment` 元数据。
|
||||||
|
|
||||||
|
`conversation-overview` 同时返回 `sourceCoverage` 与 `selection`:前者描述时间范围内源消息是否完整及 `sourceMessageCount`,后者描述从源消息中选出的 Evidence 数量及是否抽样。`evidence` 最终按 `timestamp` 升序返回,`messageRef` 是唯一推荐的消息引用。
|
||||||
|
`conversation-overview` 另有一个 `origin` 字段:`wcdb` 表示这次证据直接来自本机聊天数据库(会话概览的事实来源),`knowledge` 表示来自本地索引。
|
||||||
|
|
||||||
|
### 搜索范围(scope)
|
||||||
|
|
||||||
|
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
|
||||||
|
|
||||||
|
| scope | 含义 |
|
||||||
|
| ----- | ---- |
|
||||||
|
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
|
||||||
|
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
|
||||||
|
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
|
||||||
|
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
|
||||||
|
|
||||||
|
`conversationId` 是会话标识,可用 `/api/v1/resolve` 或 `/api/v1/contact` 得到。`scope` 一旦给出就是**权威边界**:`target` 落在范围之外会被拒绝(`status: "invalid_tool_arguments"`、`constraint: "target_outside_scope"`),不会静默扩大范围;范围里包含多个会话时,`query/messages` 与 `query/conversation-overview` 必须显式指定 `target`(`constraint: "target_required_for_scope"`)。
|
||||||
|
|
||||||
|
响应会回显实际生效的边界:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "scope": { "kind": "groups", "conversationCount": 243 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
跨会话检索时,`evidence` 的每一项都会带上它所属的会话,便于把结果归属到具体群 / 联系人与具体成员:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messageRef": "…",
|
||||||
|
"conversationName": "某个群",
|
||||||
|
"conversationType": "group",
|
||||||
|
"sender": "某成员",
|
||||||
|
"timestamp": 1789099069000,
|
||||||
|
"text": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 索引新鲜度(freshness)
|
||||||
|
|
||||||
|
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
|
||||||
|
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
|
||||||
|
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
|
||||||
|
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
|
||||||
|
|
||||||
|
调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `pending` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。
|
||||||
|
|
||||||
|
`query/messages` 与 `query/conversation-overview` 直读聊天数据库,不受索引新鲜度影响。
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内
|
|||||||
|
|
||||||
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
|
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
|
||||||
|
|
||||||
- Knowledge 正在同步时,新的分析会被暂停;
|
- Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围;
|
||||||
- 语音没有转写时,AI 可能只能看到消息类型;
|
- 语音没有转写时,AI 可能只能看到消息类型;
|
||||||
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
|
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
|
||||||
- 你只选择了一个群,答案不会自动代表所有聊天。
|
- 你只选择了一个群,答案不会自动代表所有聊天。
|
||||||
|
|
||||||
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
|
Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
|
||||||
|
|
||||||
## 这不是事实保证
|
## 这不是事实保证
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,45 @@ flowchart LR
|
|||||||
M[微信机器人消息] --> N[Agent Hub]
|
M[微信机器人消息] --> N[Agent Hub]
|
||||||
N --> B
|
N --> B
|
||||||
N --> F
|
N --> F
|
||||||
|
B --> O[Monitor / Snapshot]
|
||||||
|
O --> P[Proposed Action]
|
||||||
|
F --> P
|
||||||
|
P --> Q[Policy]
|
||||||
|
Q --> R[Action Gateway]
|
||||||
|
R --> S[Personal WeChat Send Capability]
|
||||||
|
S --> T[Action Audit / Logs]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Remember → Understand → Monitor → Act
|
||||||
|
|
||||||
|
TraceMemo 的工作方式可以概括为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Remember → Understand → Monitor → Act
|
||||||
|
```
|
||||||
|
|
||||||
|
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
|
||||||
|
|
||||||
|
## 退群监控
|
||||||
|
|
||||||
|
退群监控使用成员快照判断变化:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Current Membership → Snapshot Diff → Member Event
|
||||||
|
```
|
||||||
|
|
||||||
|
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
|
||||||
|
|
||||||
|
## 动作执行与审计
|
||||||
|
|
||||||
|
自动发送和监控动作经过统一边界:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Feature → Policy → Gateway → Capability → Execution → Audit
|
||||||
|
```
|
||||||
|
|
||||||
|
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
|
||||||
|
|
||||||
## 哪些步骤在本机
|
## 哪些步骤在本机
|
||||||
|
|
||||||
- 微信数据库读取与解析;
|
- 微信数据库读取与解析;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
|
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
|
||||||
|
|
||||||
- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`;
|
|
||||||
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`;
|
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`;
|
||||||
- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`。
|
- 控制台显示 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` 即可关闭。
|
开启后会输出 `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 二进制缺失或下载失败
|
||||||
|
|
||||||
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
|
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
|
||||||
|
|
||||||
项目的 [`.npmrc`](../../.npmrc) 已设置:
|
**先看根因,别急着删 `node_modules` 重装。** `electron@43` 的 npm 包**不再声明 `postinstall`**(其 `package.json` 里 `scripts` 是空对象),下载改为「首次 `require('electron')` 时的懒加载」。因此:
|
||||||
|
|
||||||
```ini
|
- `package.json` 里的 `pnpm.onlyBuiltDependencies: ["electron"]` 对它不起作用——上游没有脚本可执行,pnpm 无从下手;
|
||||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
- `pnpm install` 跑完不会有任何二进制被下载,**只重装依赖解决不了这个问题**。
|
||||||
```
|
|
||||||
|
|
||||||
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
|
项目已自动兜住这条路径:`scripts/ensure-electron-binary.cjs` 挂在 `postinstall` 与 `predev` 上,校验 `path.txt` 指向的可执行文件是否真的存在(只有 `path.txt` 而没有 `dist/` 同样算没装好),缺失时就地补下载。它读取 `.npmrc` 的 `electron_mirror`(当前为 `https://npmmirror.com/mirrors/electron/`),失败后再兜底重试一次该镜像。
|
||||||
|
|
||||||
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
|
正常情况下你不需要做任何事。只有当自动步骤没有执行时(例如安装时带了 `--ignore-scripts`),才需要手动补一次:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
node scripts/ensure-electron-binary.cjs
|
||||||
```
|
```
|
||||||
|
|
||||||
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
|
要换用别的镜像时,显式设置环境变量(优先于 `.npmrc`)。PowerShell 示例:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
|
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
|
||||||
pnpm install --frozen-lockfile
|
node scripts/ensure-electron-binary.cjs
|
||||||
```
|
```
|
||||||
|
|
||||||
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
|
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
|
||||||
@@ -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、微信数据路径或聊天内容。
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
- Electron + React + TypeScript;
|
- Electron + React + TypeScript;
|
||||||
- pnpm 7+;
|
- pnpm 7+;
|
||||||
- Go(构建微信连接器);
|
|
||||||
- 平台对应的 Electron/native 构建环境。
|
- 平台对应的 Electron/native 构建环境。
|
||||||
|
|
||||||
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
|
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
|
||||||
@@ -18,7 +17,7 @@ pnpm install
|
|||||||
pnpm dev
|
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: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/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
|
||||||
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
|
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
|
||||||
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
|
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
|
||||||
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
||||||
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
||||||
| `src/main/services/recall-archive-service.ts`、防撤回设置 | `user-guide/recall-protection.md`、`user-guide/privacy.md` |
|
| `src/main/services/recall-archive-service.ts` | `user-guide/privacy.md` |
|
||||||
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
||||||
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
||||||
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
|
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
|
||||||
|
|
||||||
## 文档检查
|
## 文档检查
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ pnpm test:e2e:build
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git diff --check
|
git diff --check
|
||||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!DOCUMENTATION_AUDIT.md' --glob '!development/overview.md'
|
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
|
||||||
```
|
```
|
||||||
|
|
||||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
||||||
|
|||||||
@@ -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 会直接返回错误,不会回退到另一套模型配置。
|
||||||
@@ -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
@@ -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
@@ -8,7 +8,7 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
|
|||||||
|
|
||||||
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
|
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
|
||||||
2. 确认微信数据目录指向当前账号。
|
2. 确认微信数据目录指向当前账号。
|
||||||
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
3. 只在页面明确要求时处理系统授权或 SIP;关闭 SIP 的具体步骤见[关闭 SIP 教程](../mac-disable-sip.md),按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
||||||
4. 返回应用重新检测账号、数据库和图片资源状态。
|
4. 返回应用重新检测账号、数据库和图片资源状态。
|
||||||
|
|
||||||
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
|
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
|
||||||
@@ -23,5 +23,16 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
|
|||||||
|
|
||||||
## Intel 与 Apple Silicon
|
## Intel 与 Apple Silicon
|
||||||
|
|
||||||
从 Releases 选择与 Mac 处理器匹配的构建。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
TraceMemo 同时支持两种 Mac 架构:
|
||||||
|
|
||||||
|
- **Apple Silicon(M 系列、`arm64`)**
|
||||||
|
- **Intel Mac(`x64`)**
|
||||||
|
|
||||||
|
从 Releases 下载与你 Mac 处理器匹配的构建:
|
||||||
|
|
||||||
|
- Apple Silicon:`tracememo-<版本号>-arm64.dmg`
|
||||||
|
- Intel:`tracememo-<版本号>-x64.dmg`
|
||||||
|
|
||||||
|
两种架构都可以通过应用内的连接流程自动获取微信数据库密钥。首次连接时,TraceMemo 会根据当前机器架构进入对应的流程,按连接页面提示操作即可。
|
||||||
|
|
||||||
|
Apple Silicon 和 Intel 已适配微信 macOS `4.1.13`。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
||||||
|
|||||||
@@ -27,21 +27,70 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
|
|
||||||
## 端点速查
|
## 端点速查
|
||||||
|
|
||||||
| 方法 | 路径 | 用途 |
|
| 方法 | 路径 | 用途 |
|
||||||
| ---- | --------------------- | ------------------------------------------------- |
|
| ------ | ----------------------------------- | ------------------------------------------------- |
|
||||||
| GET | `/health` | 健康和数据库状态 |
|
| GET | `/health` | 健康和数据库状态 |
|
||||||
| GET | `/current_time` | 本机时间与时区 |
|
| GET | `/current_time` | 本机时间与时区 |
|
||||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||||
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 |
|
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
||||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
||||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
||||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
||||||
| POST | `/agent/send` | 已连接机器人发送测试 |
|
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
||||||
|
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
||||||
|
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
||||||
|
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
||||||
|
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
||||||
|
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
|
||||||
|
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
|
||||||
|
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
||||||
|
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
||||||
|
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
||||||
|
| POST | `/agent/send` | 已连接机器人发送测试 |
|
||||||
|
|
||||||
|
## 定时日报管理
|
||||||
|
|
||||||
|
定时日报由 TraceMemo 自己持久化和调度。Agent 只负责理解自然语言、解析群聊和时间,再调用上述 API;不要创建 cron、维护任务文件、计算下一次执行时间或自行发送微信。
|
||||||
|
|
||||||
|
### 创建任务
|
||||||
|
|
||||||
|
用户提出“每天早上 9 点给技术交流群发昨天的日报”时,按以下顺序执行:
|
||||||
|
|
||||||
|
1. 调用 `/health`,确认 TraceMemo 和数据库可用。
|
||||||
|
2. 调用 `/wechat-personal/send-capability`,只有 `capability.status === "ready"` 且 `capability.capabilities.image === true` 才允许继续。
|
||||||
|
3. 用户使用“今天”“昨天”等相对日期时调用 `/current_time`;日报任务的 `schedule.time` 使用 TraceMemo 本机时区的 `HH:mm`,不要转成 UTC。
|
||||||
|
4. 调用 `/chatroom` 或 `/contact?type=group` 查找群聊。名称匹配多个结果时,必须把候选项展示给用户并要求选择;不能猜测。
|
||||||
|
5. 使用唯一群聊的 `talker` 创建:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "技术交流群 · 每日日报",
|
||||||
|
"group": { "talker": "xxx@chatroom", "name": "技术交流群" },
|
||||||
|
"schedule": { "type": "daily", "time": "09:00" },
|
||||||
|
"reportRange": "yesterday",
|
||||||
|
"target": { "type": "wechat_group", "talker": "xxx@chatroom" },
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
|
||||||
|
|
||||||
|
### 查看、修改和执行
|
||||||
|
|
||||||
|
- “我现在有哪些定时日报”调用 `GET /scheduled-reports`,使用返回的 `tasks` 展示任务名称、群聊、每天的时间、范围、目标和启停状态。
|
||||||
|
- 修改前先查询列表并确认唯一任务,再调用 `PATCH /scheduled-reports/:id`。只提交需要修改的字段,例如 `{"schedule":{"type":"daily","time":"10:00"}}`。
|
||||||
|
- 暂停调用 `/scheduled-reports/:id/disable`,恢复调用 `/scheduled-reports/:id/enable`。
|
||||||
|
- “现在执行一次”调用 `/scheduled-reports/:id/run`,不要改用 `/agent/group-report` 后自行发送微信;该接口和定时执行共用同一条链路。
|
||||||
|
- 查询执行结果调用 `/scheduled-reports/:id/executions`,根据 `status`、`startedAt`、`finishedAt`、`message` 和 `error` 向用户解释结果。
|
||||||
|
|
||||||
|
### 删除确认
|
||||||
|
|
||||||
|
删除是不可逆操作。收到删除请求后,先用任务列表找到唯一任务,向用户展示任务名称、时间、日报范围和发送目标并明确询问确认;只有用户明确确认后,才调用 `DELETE /scheduled-reports/:id`。
|
||||||
|
|
||||||
## 时间与上下文规则
|
## 时间与上下文规则
|
||||||
|
|
||||||
@@ -58,7 +107,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
当 `/chatlog` 返回图片消息时:
|
当 `/chatlog` 返回图片消息时:
|
||||||
|
|
||||||
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
|
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
|
||||||
2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。
|
2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。
|
||||||
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
|
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
|
||||||
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
|
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
|
||||||
5. 如果图片获取失败,明确说明无法读取图片。
|
5. 如果图片获取失败,明确说明无法读取图片。
|
||||||
@@ -71,7 +120,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
|
|
||||||
1. 调用 `/health`;必要时调用 `/current_time`。
|
1. 调用 `/health`;必要时调用 `/current_time`。
|
||||||
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
|
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
|
||||||
3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。
|
3. 请求该消息的 `media.url`,将返回的图片交给 Vision。
|
||||||
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
|
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
|
||||||
|
|
||||||
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
|
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
|
||||||
@@ -84,7 +133,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
|
|
||||||
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
||||||
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
||||||
- `404`:先用 `/resolve` 确认会话标识。
|
- `404`:会话查询失败时先用 `/resolve` 确认会话标识;媒体请求表示标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在。
|
||||||
- `422`:`messageId` 无效,或消息不是可读取的图片。
|
- `422`:媒体标识格式错误,或消息不是可读取的图片(`NOT_IMAGE`)。
|
||||||
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
||||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||||
|
|||||||
@@ -41,6 +41,19 @@
|
|||||||
|
|
||||||
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||||
|
|
||||||
|
## 查找过程和跳回原消息
|
||||||
|
|
||||||
|
查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。
|
||||||
|
|
||||||
|
查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。
|
||||||
|
|
||||||
|
点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息:
|
||||||
|
|
||||||
|
- 群聊来源打开的是那个群,而不是群里某个联系人;
|
||||||
|
- 会加载该消息前后的上下文,并滚动到它、短暂高亮;
|
||||||
|
- 只加载目标消息附近的一段,不会把整个会话历史全部读出来;
|
||||||
|
- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。
|
||||||
|
|
||||||
## 什么时候不要直接相信答案
|
## 什么时候不要直接相信答案
|
||||||
|
|
||||||
- 来源很少,或时间范围与问题不一致;
|
- 来源很少,或时间范围与问题不一致;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 选择要看的会话
|
## 选择要看的会话
|
||||||
|
|
||||||
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
左侧会话列表可以浏览已读取到的联系人、群聊和公众号。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
||||||
|
|
||||||
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
|
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
|
||||||
|
|
||||||
@@ -19,6 +19,22 @@
|
|||||||
|
|
||||||
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
|
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
|
||||||
|
|
||||||
|
## 怎么找到联系人
|
||||||
|
|
||||||
|
档案搜索会综合多个身份字段匹配联系人或群聊,包括:
|
||||||
|
|
||||||
|
- 通讯录备注(remark);
|
||||||
|
- 微信昵称;
|
||||||
|
- 当前微信号;
|
||||||
|
- wxid;
|
||||||
|
- 拼音全拼和拼音首字母。
|
||||||
|
|
||||||
|
因此可以直接输入备注、昵称、微信号或拼音查找。搜索联系人和搜索消息是两步:先确认目标会话,再在会话内查关键词;记得大意但不知道原话时,改用[AI Search](./ai-search.md)。
|
||||||
|
|
||||||
|
## 文字转语音
|
||||||
|
|
||||||
|
在档案中选择当前联系人或群聊,输入文字后生成语音,试听确认后发送。这个入口只处理明确的文字转语音动作,不是任意文本、图片或本地语音文件发送器。
|
||||||
|
|
||||||
## 消息和媒体
|
## 消息和媒体
|
||||||
|
|
||||||
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
|
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
|
||||||
@@ -27,11 +43,7 @@
|
|||||||
|
|
||||||
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
|
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
|
||||||
|
|
||||||
## 可选保留撤回消息
|
联系人和群聊列表会尽量显示头像、备注和昵称。头像或资料缺失时不影响消息读取;这通常表示本机没有对应资源,或微信没有返回完整资料。
|
||||||
|
|
||||||
“设置 → 防撤回”提供一个默认关闭的可选功能。开启后,应用会尽量保留之后捕获到的撤回消息,并在气泡旁标记“消息已撤回”。它不能找回开启前已经消失或应用未捕获到的内容,也可能增加加载开销。
|
|
||||||
|
|
||||||
该功能与普通只读浏览的数据边界不同。开启前请阅读[防撤回](./recall-protection.md)。
|
|
||||||
|
|
||||||
## 保护自己不被误导
|
## 保护自己不被误导
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,29 @@
|
|||||||
|
|
||||||
## 1. 开始前准备
|
## 1. 开始前准备
|
||||||
|
|
||||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
TraceMemo 需要读取微信本地数据库。首次使用前,请确认已安装受支持的微信客户端,并按照连接页面完成数据库密钥获取。
|
||||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
||||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
|
|
||||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
|
|
||||||
|
|
||||||
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
|
| 系统 | 微信客户端 | 首次连接说明 |
|
||||||
|
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| macOS | **微信 4.1.13 系列**(推荐 [4.1.13.8](https://github.com/zsbai/wechat-versions/releases/tag/4.1.13.8))<br>备选 [4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 支持 Apple Silicon(M 系列)和 Intel Mac 自动获取数据库密钥。首次连接时,请按照 TraceMemo 页面提示完成系统授权及微信登录操作。 |
|
||||||
|
| Windows | **微信 4.x**(推荐 [4.1.9.57](https://github.com/iibob/wechat-win-archive/releases/tag/v4.1.9.57)) | 支持自动获取数据库密钥。首次使用时请确认微信数据目录正确,无需处理 macOS 的 SIP 设置。 |
|
||||||
|
|
||||||
|
### macOS 用户
|
||||||
|
|
||||||
|
TraceMemo 已支持两种 Mac 架构:
|
||||||
|
|
||||||
|
- **Apple Silicon(M1 / M2 / M3 / M4 等)**
|
||||||
|
- **Intel Mac(x64)**
|
||||||
|
|
||||||
|
两种架构均支持自动获取微信数据库密钥,TraceMemo 会根据当前 Mac 自动选择对应的连接方式。
|
||||||
|
|
||||||
|
Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取密钥时,请让微信停留在登录页面,并按照 TraceMemo 中显示的步骤操作。
|
||||||
|
|
||||||
|
> 不同 Mac 架构的连接流程可能略有区别,请始终以应用内「第一次使用」页面显示的提示为准。
|
||||||
|
|
||||||
|
### 关于微信版本
|
||||||
|
|
||||||
|
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
|
||||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||||
@@ -26,14 +43,16 @@
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。
|
1. 从 Releases 下载 Windows x64 的 `tracememo-<版本号>-setup.exe` 安装包。
|
||||||
2. 双击安装包,按向导完成安装。
|
2. 双击安装包,按向导完成安装。
|
||||||
3. 启动 TraceMemo。
|
3. 启动 TraceMemo。
|
||||||
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
|
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
1. 下载 Apple Silicon(M 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。
|
1. 从 Releases 下载与你 Mac 处理器架构匹配的 `.dmg`:
|
||||||
|
- Apple Silicon(M 系列、`arm64`):`tracememo-<版本号>-arm64.dmg`
|
||||||
|
- Intel(`x64`):`tracememo-<版本号>-x64.dmg`
|
||||||
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
||||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||||
4. 如果系统提示应用已损坏,可在终端执行:
|
4. 如果系统提示应用已损坏,可在终端执行:
|
||||||
@@ -42,7 +61,16 @@
|
|||||||
xattr -cr "/Applications/TraceMemo.app"
|
xattr -cr "/Applications/TraceMemo.app"
|
||||||
```
|
```
|
||||||
|
|
||||||
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
5. 先让微信停在**未登录窗口**(如果微信已经登录,请退出当前账号,**只关闭窗口不算**),再启动 TraceMemo。
|
||||||
|
- 连接时会按提示自动获取数据库密钥;应用会根据当前架构进入对应流程,按连接页面显示的授权要求操作即可。
|
||||||
|
- **等第五步 明确提示可以登录后,再回到微信点击登录。** 在此之前不要先点登录,否则这次密钥获取会失败,需要重新开始。
|
||||||
|
- 只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||||
|
|
||||||
|
> **注意事项:取密钥时提示监听超时(`CAPTURE_TIMEOUT`,或相关失败)**
|
||||||
|
>
|
||||||
|
> 这说明「先让微信停在未登录窗口」这一步操作有误——常见原因是微信当时已经登录,或者还没等连接页面提示就先点了登录
|
||||||
|
>
|
||||||
|
> 请先退出微信账号、回到**未登录窗口**,然后按上面的第 5 步**重新来一遍**(等连接页面明确提示可以登录后,再回到微信点击登录)。
|
||||||
|
|
||||||
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
|
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
|
||||||
|
|
||||||
@@ -106,7 +134,6 @@
|
|||||||
- [生成群聊日报或总结](./report.md)
|
- [生成群聊日报或总结](./report.md)
|
||||||
- [转写微信语音](./voice.md)
|
- [转写微信语音](./voice.md)
|
||||||
- [导出聊天档案](./export.md)
|
- [导出聊天档案](./export.md)
|
||||||
- [可选开启防撤回](./recall-protection.md)
|
|
||||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||||
|
|
||||||
@@ -142,7 +169,6 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
|
|||||||
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
||||||
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
||||||
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
||||||
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
|
|
||||||
|
|
||||||
完整边界见[数据、隐私与安全](./privacy.md)。
|
完整边界见[数据、隐私与安全](./privacy.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Intel Mac 首次获取密钥
|
||||||
|
|
||||||
|
请先备份微信本地数据。密钥获取和数据库验证都在本机完成,不要把密钥、日志或数据库发给他人。
|
||||||
|
|
||||||
|
## 开始使用
|
||||||
|
|
||||||
|
1. 启动 TraceMemo,在首次连接页面点击“重新检查环境”。
|
||||||
|
2. 如果页面显示需要准备连接环境,点击“准备环境”,按页面提示完成。
|
||||||
|
3. 打开微信,让微信停在未登录页面,不要先点击登录。
|
||||||
|
4. 回到 TraceMemo,选择微信账号,点击“开始获取密钥”。
|
||||||
|
5. 等待页面提示可以登录后,立即回到微信点击登录。
|
||||||
|
6. 等待 TraceMemo 显示“获取成功,验证数据库连接”。
|
||||||
|
|
||||||
|
获取成功后,请保存密钥。如果页面提示需要重新准备,按页面的“查看说明”操作,不要连续重试。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 密钥只保存在本机的安全存储中。
|
||||||
|
- 请不要删除微信本地数据目录。
|
||||||
@@ -13,7 +13,30 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
|||||||
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
|
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
|
||||||
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
|
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
|
||||||
|
|
||||||
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。
|
同步在后台运行,**期间仍然可以正常提问和分析**,不会被禁用。索引还没追完时,答案会基于当前已经可用的部分给出,并在界面标注覆盖范围。
|
||||||
|
|
||||||
|
知识库卡片同时显示两组互相独立的信息:
|
||||||
|
|
||||||
|
- **规模**:已索引消息、知识片段、磁盘占用——说明索引有多大;
|
||||||
|
- **状态与本轮进度**:说明索引现在处于什么状态、这一轮同步在做什么(扫了多少、真正新增了多少、处理到第几个会话)。
|
||||||
|
|
||||||
|
`最新索引` 只表示索引已经覆盖到聊天记录的哪个时间点,**不等于**整库已经建完;进度里的计数是**本轮**的数字,不是全部历史的总数。
|
||||||
|
|
||||||
|
### 状态怎么读
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||||
|
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||||
|
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||||
|
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||||
|
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||||
|
|
||||||
|
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
|
||||||
|
|
||||||
|
### 取消和继续
|
||||||
|
|
||||||
|
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
|
||||||
|
|
||||||
## 账号隔离
|
## 账号隔离
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
|||||||
|
|
||||||
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
||||||
|
|
||||||
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
|
|
||||||
|
|
||||||
## 什么时候会请求外部服务
|
## 什么时候会请求外部服务
|
||||||
|
|
||||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
||||||
@@ -58,4 +56,3 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
|
|||||||
- 对需要外发的 AI 功能逐项确认 Provider;
|
- 对需要外发的 AI 功能逐项确认 Provider;
|
||||||
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
|
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
|
||||||
- 在共享电脑上退出应用并保护系统账户。
|
- 在共享电脑上退出应用并保护系统账户。
|
||||||
- 在开启防撤回前确认你接受其数据库写入、性能和清理边界,并先用微信官方方式备份重要数据。
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# 防撤回
|
|
||||||
|
|
||||||
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
|
||||||
|
|
||||||
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
|
||||||
|
|
||||||
## 如何开启
|
|
||||||
|
|
||||||
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
|
||||||
2. 打开“设置 → 防撤回”。
|
|
||||||
3. 阅读性能和数据提示后,开启“防撤回”。
|
|
||||||
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
|
||||||
|
|
||||||
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
|
||||||
|
|
||||||
## 当前能做什么
|
|
||||||
|
|
||||||
- 监听应用能够识别到的后续撤回变化;
|
|
||||||
- 在本地保留必要的消息和撤回关系;
|
|
||||||
- 将已识别的原消息与撤回状态一起显示在档案中;
|
|
||||||
- 按微信账号隔离 TraceMemo 保存的恢复记录。
|
|
||||||
|
|
||||||
## 当前限制
|
|
||||||
|
|
||||||
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
|
||||||
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
|
||||||
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
|
||||||
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
|
||||||
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
|
||||||
|
|
||||||
## 数据写入与关闭边界
|
|
||||||
|
|
||||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
|
||||||
|
|
||||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
|
||||||
|
|
||||||
完整的数据边界见[数据、隐私与安全](./privacy.md)。
|
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
典型场景包括:
|
典型场景包括:
|
||||||
|
|
||||||
- 整理今天工作群的讨论重点;
|
- 整理今日工作群的讨论重点;
|
||||||
- 回顾昨天错过的决定和资源;
|
- 回顾昨日错过的决定和资源;
|
||||||
- 汇总近 7 天的项目进展、待办和未解决问题;
|
- 汇总近 7 天的项目进展、待办和未解决问题;
|
||||||
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
|
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
|
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
|
||||||
|
|
||||||
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
|
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
|
||||||
2. 选择时间范围:今天、昨天或近 7 天。
|
2. 选择时间范围:今日、昨日或近 7 天。
|
||||||
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
|
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
|
||||||
4. 选择报告模板/内容模式并开始生成。
|
4. 选择报告模板/内容模式并开始生成。
|
||||||
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
|
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
|
||||||
@@ -33,10 +33,22 @@
|
|||||||
|
|
||||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||||
|
|
||||||
|
## 定时日报
|
||||||
|
|
||||||
|
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
|
||||||
|
```
|
||||||
|
|
||||||
|
生成和发送是两个阶段。当前微信发送能力不可用、未绑定或发送失败时,报告仍会保存,PNG 和执行记录也会保留;这类结果会显示为“已生成,但未发送”或“已生成,发送失败”。
|
||||||
|
|
||||||
|
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
|
||||||
|
|
||||||
## 让报告更可靠
|
## 让报告更可靠
|
||||||
|
|
||||||
- 先选正确的群和时间范围;
|
- 先选正确的群和时间范围;
|
||||||
- 不确定时先只选择文字消息;
|
- 不确定时先只选择文字消息;
|
||||||
- 群太活跃时分成“今天”和“近 7 天”两次生成;
|
- 群太活跃时分成“今日”和“近 7 天”两次生成;
|
||||||
- 看到待办和结论后回到原消息核对上下文;
|
- 看到待办和结论后回到原消息核对上下文;
|
||||||
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
|
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
|
- 确认下载的构建与 Mac 处理器匹配:Apple Silicon(M 系列)用 `arm64`,Intel 用 `x64`。
|
||||||
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
|
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
|
||||||
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
|
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
|
||||||
|
|
||||||
@@ -25,10 +26,13 @@
|
|||||||
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
|
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
|
||||||
3. 微信是否处于页面要求的登录/退出状态;
|
3. 微信是否处于页面要求的登录/退出状态;
|
||||||
4. macOS 是否完成页面要求的授权;
|
4. macOS 是否完成页面要求的授权;
|
||||||
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。
|
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题;
|
||||||
|
6. macOS 上 Apple Silicon 与 Intel 使用不同的连接流程,按连接页面提示操作;两者都可以自动获取数据库密钥。
|
||||||
|
|
||||||
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
|
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
|
||||||
|
|
||||||
|
仍然失败时,记录 **macOS 版本、CPU 架构(Apple Silicon / Intel)、微信版本、TraceMemo 版本和页面错误提示**后 扫码 README 文档二维码进群提交消息, 或者提交 Issue。
|
||||||
|
|
||||||
## 连接成功但没有联系人或消息
|
## 连接成功但没有联系人或消息
|
||||||
|
|
||||||
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
|
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
|
||||||
@@ -88,7 +92,3 @@ Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
|
|||||||
5. 需要总结或自然语言理解时,AI Provider 是否可用。
|
5. 需要总结或自然语言理解时,AI Provider 是否可用。
|
||||||
|
|
||||||
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
|
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
|
||||||
|
|
||||||
## 防撤回没有保留消息
|
|
||||||
|
|
||||||
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ asarUnpack:
|
|||||||
- node_modules/silk-wasm/**
|
- node_modules/silk-wasm/**
|
||||||
- node_modules/sherpa-onnx-node/**
|
- node_modules/sherpa-onnx-node/**
|
||||||
- node_modules/sherpa-onnx-*/**
|
- node_modules/sherpa-onnx-*/**
|
||||||
|
- node_modules/@napi-rs/system-ocr/**
|
||||||
|
- node_modules/@napi-rs/system-ocr-*/**
|
||||||
extraResources:
|
extraResources:
|
||||||
# Keep the updater provider in every packaged Windows app. electron-builder also
|
# Keep the updater provider in every packaged Windows app. electron-builder also
|
||||||
# regenerates this file during publish, using the same release configuration.
|
# regenerates this file during publish, using the same release configuration.
|
||||||
@@ -33,7 +35,6 @@ extraResources:
|
|||||||
- mobile_daily_report.html
|
- mobile_daily_report.html
|
||||||
- mobile_daily_report_v1.html
|
- mobile_daily_report_v1.html
|
||||||
- mobile_daily_report_v2.html
|
- mobile_daily_report_v2.html
|
||||||
- connectors/wechat/win32-x64/**
|
|
||||||
- key/win32/x64/**
|
- key/win32/x64/**
|
||||||
- runtime/win32/**
|
- runtime/win32/**
|
||||||
- wcdb/win32/x64/**
|
- wcdb/win32/x64/**
|
||||||
@@ -63,4 +64,6 @@ publish:
|
|||||||
provider: github
|
provider: github
|
||||||
owner: Wxw-Gu
|
owner: Wxw-Gu
|
||||||
repo: TraceMemo
|
repo: TraceMemo
|
||||||
releaseType: release
|
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||||
|
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||||
|
releaseType: draft
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ asarUnpack:
|
|||||||
- node_modules/silk-wasm/**
|
- node_modules/silk-wasm/**
|
||||||
- node_modules/sherpa-onnx-node/**
|
- node_modules/sherpa-onnx-node/**
|
||||||
- node_modules/sherpa-onnx-*/**
|
- 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:
|
extraResources:
|
||||||
# Keep the updater provider in every packaged macOS app. electron-builder also
|
# Keep the updater provider in every packaged macOS app. electron-builder also
|
||||||
# regenerates this file during publish, using the same release configuration.
|
# regenerates this file during publish, using the same release configuration.
|
||||||
@@ -71,4 +76,6 @@ publish:
|
|||||||
provider: github
|
provider: github
|
||||||
owner: Wxw-Gu
|
owner: Wxw-Gu
|
||||||
repo: TraceMemo
|
repo: TraceMemo
|
||||||
releaseType: release
|
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||||
|
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||||
|
releaseType: draft
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: resolve('src/main/index.ts'),
|
index: resolve('src/main/index.ts'),
|
||||||
|
reportTemplateTest: resolve('src/main/report-template-test-entry.ts'),
|
||||||
|
queryAgentPoc: resolve('src/main/query-agent-poc-entry.ts'),
|
||||||
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
|
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
|
||||||
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
|
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: '[name].js'
|
entryFileNames: '[name].js'
|
||||||
},
|
},
|
||||||
external: ['koffi', 'sherpa-onnx-node']
|
external: ['koffi', 'sherpa-onnx-node', '@napi-rs/system-ocr']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tracememo",
|
"name": "tracememo",
|
||||||
"version": "2.2.3",
|
"version": "2.4.0",
|
||||||
"packageManager": "pnpm@7.33.7",
|
"packageManager": "pnpm@7.33.7",
|
||||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
|
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -25,22 +25,27 @@
|
|||||||
},
|
},
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"scripts": {
|
"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 .",
|
"format": "prettier --write .",
|
||||||
"lint": "eslint --cache .",
|
"lint": "eslint --cache .",
|
||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.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",
|
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||||
"cp:env": "node scripts/ensure-env.cjs",
|
"cp:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:env": "node scripts/ensure-env.cjs",
|
"prepare:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||||
|
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
||||||
|
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
|
||||||
|
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
|
||||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"predev": "node -e \"require('electron')\"",
|
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
||||||
"dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm 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:unit": "vitest run --config vitest.unit.config.ts",
|
||||||
"test:component": "vitest run --config vitest.component.config.ts",
|
"test:component": "vitest run --config vitest.component.config.ts",
|
||||||
"test:integration": "vitest run --config vitest.integration.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:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
|
||||||
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
||||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64",
|
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs",
|
||||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
|
|
||||||
"build:native-services": "npm run build:wechat-connector",
|
|
||||||
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
|
||||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
|
||||||
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
"build:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
||||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
"build:mac:arm64": "npm run typecheck && 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": "npm run release:mac && npm run release:win",
|
||||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
"release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
|
||||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
"release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
"release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always",
|
||||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
"release:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
||||||
|
"release:beta": "cross-env EP_PRE_RELEASE=true npm run release",
|
||||||
|
"release:stable": "npm run release",
|
||||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@koromix/koffi-win32-x64": "3.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",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.23",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
@@ -90,6 +96,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
|
"css-tree": "^3.0.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ffmpeg-static": "5.3.0",
|
"ffmpeg-static": "5.3.0",
|
||||||
"fs-extra": "^11.3.2",
|
"fs-extra": "^11.3.2",
|
||||||
@@ -97,10 +104,13 @@
|
|||||||
"jsonrepair": "^3.15.0",
|
"jsonrepair": "^3.15.0",
|
||||||
"koffi": "^3.1.0",
|
"koffi": "^3.1.0",
|
||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
|
"parse5": "^8.0.0",
|
||||||
|
"pinyin-pro": "^3.26.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sherpa-onnx-node": "1.13.3",
|
"sherpa-onnx-node": "1.13.3",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"unzipper": "^0.12.0",
|
||||||
"wechat-emojis": "^1.0.2"
|
"wechat-emojis": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+87
-5
@@ -12,6 +12,8 @@ specifiers:
|
|||||||
'@electron-toolkit/tsconfig': ^2.0.0
|
'@electron-toolkit/tsconfig': ^2.0.0
|
||||||
'@electron-toolkit/utils': ^4.0.0
|
'@electron-toolkit/utils': ^4.0.0
|
||||||
'@koromix/koffi-win32-x64': 3.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
|
||||||
'@playwright/test': ^1.62.1
|
'@playwright/test': ^1.62.1
|
||||||
'@radix-ui/react-alert-dialog': ^1.1.23
|
'@radix-ui/react-alert-dialog': ^1.1.23
|
||||||
'@radix-ui/react-checkbox': ^1.3.11
|
'@radix-ui/react-checkbox': ^1.3.11
|
||||||
@@ -46,6 +48,7 @@ specifiers:
|
|||||||
class-variance-authority: ^0.7.1
|
class-variance-authority: ^0.7.1
|
||||||
clsx: ^2.1.1
|
clsx: ^2.1.1
|
||||||
cross-env: ^10.1.0
|
cross-env: ^10.1.0
|
||||||
|
css-tree: ^3.0.1
|
||||||
electron: ^43.0.0
|
electron: ^43.0.0
|
||||||
electron-builder: ^26.0.12
|
electron-builder: ^26.0.12
|
||||||
electron-updater: ^6.6.2
|
electron-updater: ^6.6.2
|
||||||
@@ -61,6 +64,8 @@ specifiers:
|
|||||||
jsonrepair: ^3.15.0
|
jsonrepair: ^3.15.0
|
||||||
koffi: ^3.1.0
|
koffi: ^3.1.0
|
||||||
openai: ^6.10.0
|
openai: ^6.10.0
|
||||||
|
parse5: ^8.0.0
|
||||||
|
pinyin-pro: ^3.26.0
|
||||||
postcss: ^8.5.26
|
postcss: ^8.5.26
|
||||||
prettier: ^3.7.4
|
prettier: ^3.7.4
|
||||||
qrcode: ^1.5.4
|
qrcode: ^1.5.4
|
||||||
@@ -73,6 +78,7 @@ specifiers:
|
|||||||
tailwindcss: 3.4.17
|
tailwindcss: 3.4.17
|
||||||
tailwindcss-animate: ^1.0.7
|
tailwindcss-animate: ^1.0.7
|
||||||
typescript: ^5.9.3
|
typescript: ^5.9.3
|
||||||
|
unzipper: ^0.12.0
|
||||||
vite: ^7.2.6
|
vite: ^7.2.6
|
||||||
vitest: ^4.1.10
|
vitest: ^4.1.10
|
||||||
wechat-emojis: ^1.0.2
|
wechat-emojis: ^1.0.2
|
||||||
@@ -82,6 +88,8 @@ dependencies:
|
|||||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||||
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
||||||
'@koromix/koffi-win32-x64': 3.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-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
|
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
'@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
|
'@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
@@ -101,6 +109,7 @@ dependencies:
|
|||||||
class-variance-authority: 0.7.1
|
class-variance-authority: 0.7.1
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
cross-env: 10.1.0
|
cross-env: 10.1.0
|
||||||
|
css-tree: 3.2.1
|
||||||
electron-updater: 6.8.9
|
electron-updater: 6.8.9
|
||||||
ffmpeg-static: 5.3.0
|
ffmpeg-static: 5.3.0
|
||||||
fs-extra: 11.3.2
|
fs-extra: 11.3.2
|
||||||
@@ -108,10 +117,13 @@ dependencies:
|
|||||||
jsonrepair: 3.15.0
|
jsonrepair: 3.15.0
|
||||||
koffi: 3.1.0
|
koffi: 3.1.0
|
||||||
openai: 6.10.0
|
openai: 6.10.0
|
||||||
|
parse5: 8.0.1
|
||||||
|
pinyin-pro: 3.29.3
|
||||||
qrcode: 1.5.4
|
qrcode: 1.5.4
|
||||||
sherpa-onnx-node: 1.13.3
|
sherpa-onnx-node: 1.13.3
|
||||||
silk-wasm: 3.7.1
|
silk-wasm: 3.7.1
|
||||||
tailwind-merge: 3.6.0
|
tailwind-merge: 3.6.0
|
||||||
|
unzipper: 0.12.5
|
||||||
wechat-emojis: 1.0.2
|
wechat-emojis: 1.0.2
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -1677,6 +1689,44 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/@nodelib/fs.scandir/2.1.5:
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3898,6 +3948,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/bluebird/3.7.2:
|
||||||
|
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/brace-expansion/1.1.12:
|
/brace-expansion/1.1.12:
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4350,7 +4404,6 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mdn-data: 2.27.1
|
mdn-data: 2.27.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/css.escape/1.5.1:
|
/css.escape/1.5.1:
|
||||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||||
@@ -4570,6 +4623,12 @@ packages:
|
|||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/duplexer2/0.1.4:
|
||||||
|
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
|
||||||
|
dependencies:
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
dev: false
|
||||||
|
|
||||||
/eastasianwidth/0.2.0:
|
/eastasianwidth/0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -4697,7 +4756,6 @@ packages:
|
|||||||
/entities/8.0.0:
|
/entities/8.0.0:
|
||||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/env-paths/2.2.1:
|
/env-paths/2.2.1:
|
||||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
@@ -5301,6 +5359,15 @@ packages:
|
|||||||
jsonfile: 6.2.0
|
jsonfile: 6.2.0
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
|
|
||||||
|
/fs-extra/11.3.1:
|
||||||
|
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
|
||||||
|
engines: {node: '>=14.14'}
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
jsonfile: 6.2.0
|
||||||
|
universalify: 2.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/fs-extra/11.3.2:
|
/fs-extra/11.3.2:
|
||||||
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
@@ -6326,7 +6393,6 @@ packages:
|
|||||||
|
|
||||||
/mdn-data/2.27.1:
|
/mdn-data/2.27.1:
|
||||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/merge2/1.4.1:
|
/merge2/1.4.1:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
@@ -6552,6 +6618,10 @@ packages:
|
|||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/node-int64/0.4.0:
|
||||||
|
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/node-releases/2.0.27:
|
/node-releases/2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -6771,7 +6841,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 8.0.0
|
entities: 8.0.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/path-exists/4.0.0:
|
/path-exists/4.0.0:
|
||||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
@@ -6835,6 +6904,10 @@ packages:
|
|||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/pinyin-pro/3.29.3:
|
||||||
|
resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/pirates/4.0.7:
|
/pirates/4.0.7:
|
||||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -7659,7 +7732,6 @@ packages:
|
|||||||
/source-map-js/1.2.1:
|
/source-map-js/1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/source-map-support/0.5.21:
|
/source-map-support/0.5.21:
|
||||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||||
@@ -8204,6 +8276,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
/unzipper/0.12.5:
|
||||||
|
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
|
||||||
|
dependencies:
|
||||||
|
bluebird: 3.7.2
|
||||||
|
duplexer2: 0.1.4
|
||||||
|
fs-extra: 11.3.1
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
node-int64: 0.4.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/update-browserslist-db/1.2.2_browserslist@4.28.1:
|
/update-browserslist-db/1.2.2_browserslist@4.28.1:
|
||||||
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
|
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 153 KiB |
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 |
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -68,21 +68,29 @@
|
|||||||
.overview {
|
.overview {
|
||||||
margin-top: 2px;
|
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 {
|
.avatar-grid {
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
display: grid;
|
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;
|
gap: 3px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
|
||||||
.avatar-grid.avatar-count-1 {
|
.avatar-grid.avatar-count-1 {
|
||||||
width: 28px;
|
grid-template-columns: var(--tm-avatar-hero-size, 40px);
|
||||||
height: 28px;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.avatar-grid.avatar-count-2 {
|
|
||||||
height: 28px;
|
|
||||||
}
|
}
|
||||||
.avatar-grid.empty-section {
|
.avatar-grid.empty-section {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -61,21 +61,29 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
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 {
|
.avatar-grid {
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
display: grid;
|
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;
|
gap: 3px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
|
||||||
.avatar-grid.avatar-count-1 {
|
.avatar-grid.avatar-count-1 {
|
||||||
width: 28px;
|
grid-template-columns: var(--tm-avatar-hero-size, 40px);
|
||||||
height: 28px;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.avatar-grid.avatar-count-2 {
|
|
||||||
height: 28px;
|
|
||||||
}
|
}
|
||||||
.avatar-grid.empty-section {
|
.avatar-grid.empty-section {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
|
||||||
+241
-5
@@ -1,8 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||||
const { chmodSync, existsSync } = require('node:fs')
|
const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs')
|
||||||
const { execFileSync } = require('node:child_process')
|
const { execFileSync } = require('node:child_process')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const asar = require('@electron/asar')
|
const asar = require('@electron/asar')
|
||||||
|
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||||
|
|
||||||
const REQUIRED_RUNTIME_PACKAGES = [
|
const REQUIRED_RUNTIME_PACKAGES = [
|
||||||
'@electron-toolkit/preload',
|
'@electron-toolkit/preload',
|
||||||
@@ -15,6 +16,15 @@ const REQUIRED_RUNTIME_PACKAGES = [
|
|||||||
'koffi'
|
'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) {
|
function getRuntimeResources(context) {
|
||||||
const productName = context.packager.appInfo.productFilename
|
const productName = context.packager.appInfo.productFilename
|
||||||
return context.electronPlatformName === 'darwin'
|
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) {
|
function normalizeBuilderArch(arch) {
|
||||||
if (typeof arch === 'string') return arch
|
if (typeof arch === 'string') return arch
|
||||||
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function validateAsarRuntimeDependencies(runtimeResources) {
|
||||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||||
@@ -107,22 +249,108 @@ function validateReaderSkillRuntime(runtimeResources) {
|
|||||||
return skillPath
|
return skillPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native runtime packages are published once per platform-arch pair, and pnpm
|
||||||
|
* installs all of them, so every bundle ends up carrying the native libraries
|
||||||
|
* of every platform (measured: ~129MB of speech models plus ~16MB of koffi).
|
||||||
|
* The loaders pick their package from process.platform/arch, so the siblings
|
||||||
|
* are dead weight — drop them.
|
||||||
|
*/
|
||||||
|
// 每个条目返回 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) {
|
exports.default = async function afterPack(context) {
|
||||||
const runtimeResources = getRuntimeResources(context)
|
const runtimeResources = getRuntimeResources(context)
|
||||||
|
const arch = normalizeBuilderArch(context.arch)
|
||||||
validateAsarRuntimeDependencies(runtimeResources)
|
validateAsarRuntimeDependencies(runtimeResources)
|
||||||
validateReaderSkillRuntime(runtimeResources)
|
validateReaderSkillRuntime(runtimeResources)
|
||||||
validateSilkWasmRuntime(runtimeResources)
|
validateSilkWasmRuntime(runtimeResources)
|
||||||
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
||||||
validateSherpaRuntime(
|
validateRuntimeBinaryArchitecture(
|
||||||
runtimeResources,
|
ffmpegPath,
|
||||||
context.electronPlatformName,
|
context.electronPlatformName,
|
||||||
normalizeBuilderArch(context.arch)
|
arch,
|
||||||
|
'Bundled ffmpeg'
|
||||||
)
|
)
|
||||||
|
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||||
|
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||||
|
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
|
||||||
|
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
|
||||||
|
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
|
||||||
|
|
||||||
if (context.electronPlatformName === 'darwin') {
|
if (context.electronPlatformName === 'darwin') {
|
||||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
||||||
stdio: 'ignore'
|
stdio: 'ignore'
|
||||||
})
|
})
|
||||||
|
signMacosHelpers(runtimeResources)
|
||||||
|
const productName = context.packager.appInfo.productFilename
|
||||||
|
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.electronPlatformName === 'win32') {
|
if (context.electronPlatformName === 'win32') {
|
||||||
@@ -141,7 +369,6 @@ exports.default = async function afterPack(context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.getRuntimeResources = getRuntimeResources
|
exports.getRuntimeResources = getRuntimeResources
|
||||||
@@ -150,3 +377,12 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
|
|||||||
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
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
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const fs = require('node:fs')
|
||||||
|
|
||||||
|
const MACHO_MAGIC_32 = 0xfeedface
|
||||||
|
const MACHO_MAGIC_64 = 0xfeedfacf
|
||||||
|
const FAT_MAGIC = 0xcafebabe
|
||||||
|
const FAT_MAGIC_64 = 0xcafebabf
|
||||||
|
const PE_SIGNATURE = 0x00004550
|
||||||
|
const PE_MACHINE_X64 = 0x8664
|
||||||
|
const PE_MACHINE_ARM64 = 0xaa64
|
||||||
|
|
||||||
|
const CPU_TYPE_IA32 = 0x00000007
|
||||||
|
const CPU_TYPE_X86_64 = 0x01000007
|
||||||
|
const CPU_TYPE_ARM64 = 0x0100000c
|
||||||
|
|
||||||
|
/** Only the headers are needed; native binaries can be tens of megabytes. */
|
||||||
|
const HEADER_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
function readHeader(filePath) {
|
||||||
|
const descriptor = fs.openSync(filePath, 'r')
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(HEADER_BYTES)
|
||||||
|
const bytesRead = fs.readSync(descriptor, buffer, 0, HEADER_BYTES, 0)
|
||||||
|
return buffer.subarray(0, bytesRead)
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMachoCpuType(cpuType) {
|
||||||
|
if (cpuType === CPU_TYPE_X86_64) return 'x64'
|
||||||
|
if (cpuType === CPU_TYPE_ARM64) return 'arm64'
|
||||||
|
if (cpuType === CPU_TYPE_IA32) return 'ia32'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns every architecture contained in a Mach-O or PE binary, as
|
||||||
|
* electron-builder arch names ("x64", "arm64"). Universal binaries report both.
|
||||||
|
* Returns an empty array for anything that is not a native executable (scripts,
|
||||||
|
* wasm), so callers can treat "unknown" separately from "wrong architecture".
|
||||||
|
*/
|
||||||
|
function readBinaryArchitectures(filePath) {
|
||||||
|
let buffer
|
||||||
|
try {
|
||||||
|
buffer = readHeader(filePath)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (buffer.length < 8) return []
|
||||||
|
|
||||||
|
const fatMagic = buffer.readUInt32BE(0)
|
||||||
|
if (fatMagic === FAT_MAGIC || fatMagic === FAT_MAGIC_64) {
|
||||||
|
const entrySize = fatMagic === FAT_MAGIC_64 ? 32 : 20
|
||||||
|
const count = Math.min(buffer.readUInt32BE(4), 32)
|
||||||
|
const architectures = []
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const entryOffset = 8 + index * entrySize
|
||||||
|
if (entryOffset + 4 > buffer.length) break
|
||||||
|
const name = normalizeMachoCpuType(buffer.readUInt32BE(entryOffset))
|
||||||
|
if (name && !architectures.includes(name)) architectures.push(name)
|
||||||
|
}
|
||||||
|
return architectures
|
||||||
|
}
|
||||||
|
|
||||||
|
const thinMagic = buffer.readUInt32LE(0)
|
||||||
|
if (thinMagic === MACHO_MAGIC_32 || thinMagic === MACHO_MAGIC_64) {
|
||||||
|
const name = normalizeMachoCpuType(buffer.readUInt32LE(4))
|
||||||
|
return name ? [name] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.readUInt16LE(0) === 0x5a4d) {
|
||||||
|
const peOffset = buffer.readUInt32LE(0x3c)
|
||||||
|
if (peOffset + 6 > buffer.length || buffer.readUInt32LE(peOffset) !== PE_SIGNATURE) return []
|
||||||
|
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||||
|
if (machine === PE_MACHINE_X64) return ['x64']
|
||||||
|
if (machine === PE_MACHINE_ARM64) return ['arm64']
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { readBinaryArchitectures }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const { ZipArchive } = require('archiver')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const source = path.join(root, 'examples', 'report-template-basic')
|
||||||
|
const outputPath = path.join(root, 'examples', 'report-template-basic.zip')
|
||||||
|
const output = fs.createWriteStream(outputPath)
|
||||||
|
const archive = new ZipArchive({ zlib: { level: 9 } })
|
||||||
|
output.on('close', () => console.log(`wrote ${outputPath} (${archive.pointer()} bytes)`))
|
||||||
|
archive.on('error', (error) => { throw error })
|
||||||
|
archive.pipe(output)
|
||||||
|
archive.file(path.join(source, 'manifest.json'), { name: 'manifest.json' })
|
||||||
|
archive.file(path.join(source, 'template.html'), { name: 'template.html' })
|
||||||
|
// 使用 1x1 PNG 作为虚构预览占位图,避免引入真实用户媒体。
|
||||||
|
archive.append(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), { name: 'preview.png' })
|
||||||
|
archive.finalize()
|
||||||
@@ -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}`)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* electron@43 的 npm 包不再声明 postinstall(它自己的 package.json 里 scripts 是空的),
|
||||||
|
* 下载被改成「首次 require('electron') 时的懒加载」。后果是 `pnpm install` 之后
|
||||||
|
* node_modules/electron 里只有一个空壳:dist/ 与 path.txt 都不在,而
|
||||||
|
* package.json 里的 pnpm.onlyBuiltDependencies: ["electron"] 对着空气发号施令 ——
|
||||||
|
* 上游没有脚本可跑,pnpm 自然什么也不做。新 clone 于是直到 `pnpm dev` 才炸。
|
||||||
|
* 这里显式补上下载,并把它挂在 postinstall / predev 上。
|
||||||
|
*/
|
||||||
|
const MIRROR_FALLBACK = 'https://npmmirror.com/mirrors/electron/'
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
function resolveElectronPackageRoot() {
|
||||||
|
try {
|
||||||
|
return path.dirname(require.resolve('electron/package.json'))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* .npmrc 的 electron_mirror 只有在 npm/pnpm 执行脚本时才会变成 npm_config_* 环境变量;
|
||||||
|
* 直接 `node node_modules/electron/install.js` 时读不到,于是会绕开镜像去打 GitHub。
|
||||||
|
* 这里显式读出来当 ELECTRON_MIRROR 传下去。显式设置的环境变量优先。
|
||||||
|
*/
|
||||||
|
function resolveMirror() {
|
||||||
|
if (process.env.ELECTRON_MIRROR) return process.env.ELECTRON_MIRROR
|
||||||
|
try {
|
||||||
|
const npmrc = fs.readFileSync(path.join(projectRoot, '.npmrc'), 'utf8')
|
||||||
|
const match = npmrc.match(/^\s*electron_mirror\s*=\s*(\S+)\s*$/m)
|
||||||
|
return match ? match[1] : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* path.txt 只是 electron 写下的相对路径,光有它不算装好 —— 指向的可执行文件
|
||||||
|
* 必须真的存在,否则仍会在启动时报「Electron failed to install correctly」。
|
||||||
|
*/
|
||||||
|
function isElectronBinaryInstalled(packageRoot) {
|
||||||
|
if (!packageRoot) return false
|
||||||
|
try {
|
||||||
|
const executable = fs.readFileSync(path.join(packageRoot, 'path.txt'), 'utf8').trim()
|
||||||
|
return executable !== '' && fs.existsSync(path.join(packageRoot, 'dist', executable))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runInstaller(packageRoot) {
|
||||||
|
const installer = path.join(packageRoot, 'install.js')
|
||||||
|
if (!fs.existsSync(installer)) {
|
||||||
|
throw new Error(`[ensure-electron] missing ${installer}; run pnpm install first`)
|
||||||
|
}
|
||||||
|
const mirror = resolveMirror()
|
||||||
|
const attempts = mirror
|
||||||
|
? [{ label: mirror, env: { ELECTRON_MIRROR: mirror } }]
|
||||||
|
: [{ label: 'default source', env: {} }]
|
||||||
|
// 配的镜像本身不是 npmmirror 时,再兜一层:镜像挂掉时不至于完全没退路。
|
||||||
|
if (!mirror.includes('npmmirror.com')) {
|
||||||
|
attempts.push({ label: MIRROR_FALLBACK, env: { ELECTRON_MIRROR: MIRROR_FALLBACK } })
|
||||||
|
}
|
||||||
|
let lastError
|
||||||
|
for (let index = 0; index < attempts.length; index += 1) {
|
||||||
|
const attempt = attempts[index]
|
||||||
|
console.log(`[ensure-electron] downloading from ${attempt.label}`)
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, [installer], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, ...attempt.env }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const next = attempts[index + 1]
|
||||||
|
if (next) {
|
||||||
|
console.warn(`[ensure-electron] download failed, retrying from ${next.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureElectronBinary() {
|
||||||
|
const packageRoot = resolveElectronPackageRoot()
|
||||||
|
if (isElectronBinaryInstalled(packageRoot)) return false
|
||||||
|
console.log('[ensure-electron] Electron binary is missing; downloading it now')
|
||||||
|
runInstaller(packageRoot)
|
||||||
|
if (!isElectronBinaryInstalled(packageRoot)) {
|
||||||
|
throw new Error('[ensure-electron] Electron binary is still missing after installing')
|
||||||
|
}
|
||||||
|
console.log('[ensure-electron] Electron binary is ready')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) ensureElectronBinary()
|
||||||
|
|
||||||
|
module.exports = { ensureElectronBinary, isElectronBinaryInstalled, resolveElectronPackageRoot }
|
||||||
@@ -98,7 +98,7 @@ const activityLine = Array.from(document.querySelectorAll('.analytics > .card'))
|
|||||||
const values = {
|
const values = {
|
||||||
REPORT_TITLE: title,
|
REPORT_TITLE: title,
|
||||||
REPORT_DATE: reportDate,
|
REPORT_DATE: reportDate,
|
||||||
DATE_RANGE: '今天',
|
DATE_RANGE: '今日',
|
||||||
TIME_SPAN: statValues[2] || dateTimeRange,
|
TIME_SPAN: statValues[2] || dateTimeRange,
|
||||||
HERO_SUMMARY: overview,
|
HERO_SUMMARY: overview,
|
||||||
HERO_TAKEAWAY: '',
|
HERO_TAKEAWAY: '',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const { execFileSync } = require('node:child_process')
|
const { execFileSync } = require('node:child_process')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
|
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||||
|
|
||||||
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
|
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
|
||||||
|
|
||||||
@@ -24,6 +25,31 @@ function readOption(name, fallback) {
|
|||||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
|
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ffmpegExecutableName(targetPlatform) {
|
||||||
|
return targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ffmpeg-static keeps a single binary per platform ("ffmpeg" everywhere except
|
||||||
|
* Windows), so an arm64 and an x64 macOS checkout cannot coexist in
|
||||||
|
* node_modules. Its installer also exits early whenever the file already
|
||||||
|
* exists, so a binary left over from the other architecture would be packed
|
||||||
|
* silently. Check the real architecture and drop the file when it differs, so
|
||||||
|
* the caller re-downloads the requested one.
|
||||||
|
*/
|
||||||
|
function ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch) {
|
||||||
|
if (!fs.existsSync(ffmpegPath)) return 'missing'
|
||||||
|
const architectures = readBinaryArchitectures(ffmpegPath)
|
||||||
|
if (architectures.includes(targetArch)) return 'match'
|
||||||
|
console.log(
|
||||||
|
`[prepare-electron-runtime] ffmpeg-static is ${
|
||||||
|
architectures.join('/') || 'not a native binary'
|
||||||
|
} but ${targetPlatform}-${targetArch} was requested; replacing it`
|
||||||
|
)
|
||||||
|
fs.rmSync(ffmpegPath, { force: true })
|
||||||
|
return 'replaced'
|
||||||
|
}
|
||||||
|
|
||||||
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
|
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
|
||||||
let packageRoot = ''
|
let packageRoot = ''
|
||||||
try {
|
try {
|
||||||
@@ -31,10 +57,11 @@ function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = pr
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
const executable = ffmpegExecutableName(targetPlatform)
|
||||||
const ffmpegPath = path.join(packageRoot, executable)
|
const ffmpegPath = path.join(packageRoot, executable)
|
||||||
|
const architectureState = ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch)
|
||||||
|
|
||||||
if (!fs.existsSync(ffmpegPath)) {
|
if (architectureState !== 'match') {
|
||||||
const installScript = path.join(packageRoot, 'install.js')
|
const installScript = path.join(packageRoot, 'install.js')
|
||||||
console.log(
|
console.log(
|
||||||
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
|
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
|
||||||
@@ -83,4 +110,6 @@ function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
if (require.main === module) main()
|
||||||
|
|
||||||
|
module.exports = { ensureFfmpegArchitecture, ffmpegExecutableName, prepareFfmpegRuntime }
|
||||||
|
|||||||
@@ -86,41 +86,115 @@ for (const required of [executable, script, config]) {
|
|||||||
|
|
||||||
function patchPerSendPayload(scriptPath) {
|
function patchPerSendPayload(scriptPath) {
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||||
|
|
||||||
const declarations = 'var triggerX1Payload;\nvar triggerX0;'
|
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||||
const patchedDeclarations =
|
|
||||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
|
|
||||||
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
|
|
||||||
triggerX1Payload.writeByteArray(payloadData);
|
|
||||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
|
||||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
|
||||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
|
||||||
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
|
|
||||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||||
|
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||||
|
triggerX1Payload.writeByteArray(payloadData);
|
||||||
|
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||||
|
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||||
|
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||||
|
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||||
|
source = source
|
||||||
|
.replace(
|
||||||
|
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
|
||||||
|
'var triggerX1Payload;\nvar triggerX0;'
|
||||||
|
)
|
||||||
|
.replace(activeSend, upstreamSend)
|
||||||
|
.replace(
|
||||||
|
' MMStartTask(triggerX0, activeTriggerX1Payload);',
|
||||||
|
' MMStartTask(triggerX0, triggerX1Payload);'
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||||
|
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
|
||||||
|
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
|
||||||
|
)
|
||||||
|
fs.writeFileSync(scriptPath, source)
|
||||||
|
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
|
||||||
|
}
|
||||||
|
|
||||||
if (!source.includes(declarations) || !source.includes(originalSend)) {
|
function patchSendContextCapture(scriptPath, strict = true) {
|
||||||
throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('function isLikelySendContext(')) return
|
||||||
|
const original = `function AttachSendFunc() {
|
||||||
|
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||||
|
onEnter: function (args) {
|
||||||
|
|
||||||
|
if (triggerX1Payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerX0 = this.context.x0;
|
||||||
|
triggerX1Payload = this.context.x1;
|
||||||
|
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}`
|
||||||
|
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
|
||||||
|
try {
|
||||||
|
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
|
||||||
|
var manager = readPointerIfReadable(candidateX0.add(0x18));
|
||||||
|
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
|
||||||
|
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
|
||||||
|
return !manager.equals(ptr(0));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[debug] StartTask candidate inspect failed: " + e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachSendFunc() {
|
||||||
|
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||||
|
onEnter: function (args) {
|
||||||
|
if (triggerX1Payload) return;
|
||||||
|
var candidateX0 = this.context.x0;
|
||||||
|
var candidateX1 = this.context.x1;
|
||||||
|
if (!isLikelySendContext(candidateX0, candidateX1)) return;
|
||||||
|
triggerX0 = candidateX0;
|
||||||
|
triggerX1Payload = candidateX1;
|
||||||
|
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}`
|
||||||
|
if (!source.includes(original)) {
|
||||||
|
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source = source.replace(original, patched)
|
||||||
|
fs.writeFileSync(scriptPath, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchVoiceAudioBuffer(scriptPath) {
|
||||||
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
|
||||||
|
|
||||||
|
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
|
||||||
|
if (!source.includes(staticAllocation)) {
|
||||||
|
throw new Error('无法定位 wechat_chatter 语音缓冲区')
|
||||||
}
|
}
|
||||||
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
|
|
||||||
source = source.replace(
|
source = source.replace(
|
||||||
' MMStartTask(triggerX0, triggerX1Payload);',
|
staticAllocation,
|
||||||
' MMStartTask(triggerX0, activeTriggerX1Payload);'
|
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
|
||||||
)
|
)
|
||||||
|
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
|
||||||
|
if (!source.includes(audioLengthMarker)) {
|
||||||
|
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
|
||||||
|
}
|
||||||
source = source.replace(
|
source = source.replace(
|
||||||
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
audioLengthMarker,
|
||||||
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
|
||||||
)
|
|
||||||
source = source.replace(
|
|
||||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
|
|
||||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
|
|
||||||
)
|
)
|
||||||
fs.writeFileSync(scriptPath, source)
|
fs.writeFileSync(scriptPath, source)
|
||||||
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
|
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchImageHookReadiness(scriptPath) {
|
function patchImageHookReadiness(scriptPath) {
|
||||||
@@ -146,6 +220,163 @@ function patchImageHookReadiness(scriptPath) {
|
|||||||
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backport of wechat_chatter PR #36 by @Leslielu:
|
||||||
|
// https://github.com/yincongcyincong/wechat_chatter/pull/36
|
||||||
|
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
|
||||||
|
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
|
||||||
|
function patchCdnColdStart(scriptPath) {
|
||||||
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
|
if (source.includes('function resolveCdnManager()')) return
|
||||||
|
|
||||||
|
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||||
|
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
|
||||||
|
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||||
|
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
|
||||||
|
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
|
||||||
|
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
|
||||||
|
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
|
||||||
|
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||||
|
source = source.replace(initAddresses, patchedInitAddresses)
|
||||||
|
|
||||||
|
const downloadChunkEnd = `}
|
||||||
|
|
||||||
|
function fillUploadX1AndStart`
|
||||||
|
const resolver = `}
|
||||||
|
|
||||||
|
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
|
||||||
|
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
|
||||||
|
function resolveCdnManager() {
|
||||||
|
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
|
||||||
|
return ptr(0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
|
||||||
|
var strDefault = Memory.alloc(24);
|
||||||
|
strDefault.writeUtf8String("default");
|
||||||
|
strDefault.add(0x17).writeU8(7);
|
||||||
|
|
||||||
|
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
|
||||||
|
var svc = getService(strDefault);
|
||||||
|
if (!isReadablePointer(svc)) {
|
||||||
|
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
|
||||||
|
return ptr(0);
|
||||||
|
}
|
||||||
|
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
|
||||||
|
var ctx = getCtx(svc);
|
||||||
|
if (!isReadablePointer(ctx)) {
|
||||||
|
console.error("[!] CdnManager getter 返回不可读: " + ctx);
|
||||||
|
return ptr(0);
|
||||||
|
}
|
||||||
|
var mgr = readPointerIfReadable(ctx.add(0x40));
|
||||||
|
if (!isReadablePointer(mgr)) {
|
||||||
|
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
|
||||||
|
return ptr(0);
|
||||||
|
}
|
||||||
|
return mgr;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[!] resolveCdnManager 异常: " + e);
|
||||||
|
return ptr(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCdnManagerX0() {
|
||||||
|
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
|
||||||
|
uploadGlobalX0 = downloadGlobalX0;
|
||||||
|
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||||
|
}
|
||||||
|
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
|
||||||
|
downloadGlobalX0 = uploadGlobalX0;
|
||||||
|
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||||
|
}
|
||||||
|
if (uploadGlobalX0.equals(ptr(0))) {
|
||||||
|
var mgr = resolveCdnManager();
|
||||||
|
if (!mgr.equals(ptr(0))) {
|
||||||
|
uploadGlobalX0 = mgr;
|
||||||
|
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||||
|
downloadGlobalX0 = mgr;
|
||||||
|
}
|
||||||
|
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !uploadGlobalX0.equals(ptr(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillUploadX1AndStart`
|
||||||
|
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
|
||||||
|
source = source.replace(downloadChunkEnd, resolver)
|
||||||
|
|
||||||
|
const declarations = 'var uploadImageAddr;\n'
|
||||||
|
const patchedDeclarations =
|
||||||
|
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
|
||||||
|
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
|
||||||
|
source = source.replace(declarations, patchedDeclarations)
|
||||||
|
|
||||||
|
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||||
|
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||||
|
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||||
|
if (uploadGlobalX0.equals(ptr(0))) {
|
||||||
|
ensureCdnManagerX0();
|
||||||
|
}
|
||||||
|
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||||
|
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
|
||||||
|
source = source.replace(uploadGuard, patchedUploadGuard)
|
||||||
|
|
||||||
|
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||||
|
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||||
|
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||||
|
if (uploadGlobalX0.equals(ptr(0))) {
|
||||||
|
ensureCdnManagerX0();
|
||||||
|
}
|
||||||
|
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||||
|
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
|
||||||
|
source = source.replace(voiceGuard, patchedVoiceGuard)
|
||||||
|
|
||||||
|
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||||
|
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
|
||||||
|
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
|
||||||
|
downloadGlobalX0 = capturedUploadX0;
|
||||||
|
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||||
|
}`
|
||||||
|
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
|
||||||
|
source = source.replace(uploadHook, patchedUploadHook)
|
||||||
|
|
||||||
|
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
|
||||||
|
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
|
||||||
|
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
|
||||||
|
uploadGlobalX0 = downloadGlobalX0;
|
||||||
|
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||||
|
}`
|
||||||
|
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
|
||||||
|
source = source.replace(downloadHook, patchedDownloadHook)
|
||||||
|
|
||||||
|
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||||
|
if (!downloadGlobalX0) {`
|
||||||
|
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||||
|
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||||
|
ensureCdnManagerX0();
|
||||||
|
}
|
||||||
|
if (!downloadGlobalX0) {`
|
||||||
|
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
|
||||||
|
source = source.replace(downloadGuard, patchedDownloadGuard)
|
||||||
|
|
||||||
|
fs.writeFileSync(scriptPath, source)
|
||||||
|
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchCdnColdStartConfig(configPath) {
|
||||||
|
const source = fs.readFileSync(configPath, 'utf8')
|
||||||
|
let config
|
||||||
|
try {
|
||||||
|
config = JSON.parse(source)
|
||||||
|
} catch {
|
||||||
|
throw new Error('4.1.11.53 版本配置不是有效 JSON')
|
||||||
|
}
|
||||||
|
config.cdnGetServiceAddr = '0x50a15d0'
|
||||||
|
config.cdnManagerGetterAddr = '0x5259290'
|
||||||
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||||
|
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
|
||||||
|
}
|
||||||
|
|
||||||
function patchWechatCoreModuleBase(scriptPath) {
|
function patchWechatCoreModuleBase(scriptPath) {
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
if (source.includes('WeChat core module base:')) return
|
if (source.includes('WeChat core module base:')) return
|
||||||
@@ -182,7 +413,8 @@ function addModifiedWorkNotice(scriptPath) {
|
|||||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||||
* Runtime version: v0.0.18
|
* Runtime version: v0.0.18
|
||||||
* License: GNU General Public License version 3 (GPL-3.0)
|
* License: GNU General Public License version 3 (GPL-3.0)
|
||||||
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
|
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
|
||||||
|
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
|
||||||
* These modifications are not provided by the upstream author.
|
* These modifications are not provided by the upstream author.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -194,7 +426,11 @@ function addModifiedWorkNotice(scriptPath) {
|
|||||||
|
|
||||||
patchWechatCoreModuleBase(script)
|
patchWechatCoreModuleBase(script)
|
||||||
patchPerSendPayload(script)
|
patchPerSendPayload(script)
|
||||||
|
patchSendContextCapture(script)
|
||||||
|
patchVoiceAudioBuffer(script)
|
||||||
patchImageHookReadiness(script)
|
patchImageHookReadiness(script)
|
||||||
|
patchCdnColdStartConfig(config)
|
||||||
|
patchCdnColdStart(script)
|
||||||
addModifiedWorkNotice(script)
|
addModifiedWorkNotice(script)
|
||||||
fs.chmodSync(executable, 0o755)
|
fs.chmodSync(executable, 0o755)
|
||||||
console.log('[wechat-personal] 运行时准备完成')
|
console.log('[wechat-personal] 运行时准备完成')
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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>`
|
`<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')}`
|
).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 = {
|
const avatars = {
|
||||||
阿宇: avatarSvg('宇', '#dcfce7'),
|
阿宇: avatarSvg('宇', '#dcfce7'),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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: 
|
|
||||||
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  and "
|
|
||||||
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 := ""
|
|
||||||
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] + "..."
|
|
||||||
}
|
|
||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_DEEPSEEK_API_KEY: string
|
readonly VITE_DEEPSEEK_API_KEY: string
|
||||||
|
readonly VITE_SCHEDULED_REPORT_DEBUG: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -329,6 +329,32 @@ body {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.timeline-month small { color: inherit; }
|
.timeline-month small { color: inherit; }
|
||||||
|
.timeline-month-entry { display: grid; gap: 2px; }
|
||||||
|
.timeline-days {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
margin: 0 0 4px 11px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.timeline-days[hidden] { display: none; }
|
||||||
|
.timeline-day {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 5px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.timeline-day:hover, .timeline-day.active { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.timeline-day small { color: inherit; }
|
||||||
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
|
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
|
||||||
.lazy-hint {
|
.lazy-hint {
|
||||||
width: min(100%, 820px);
|
width: min(100%, 820px);
|
||||||
@@ -787,6 +813,23 @@ body {
|
|||||||
}
|
}
|
||||||
.timeline-months { display: flex; gap: 6px; }
|
.timeline-months { display: flex; gap: 6px; }
|
||||||
.timeline-months[hidden] { display: none; }
|
.timeline-months[hidden] { display: none; }
|
||||||
|
.timeline-month-entry { display: grid; gap: 2px; }
|
||||||
|
.timeline-days {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
.timeline-days[hidden] { display: none; }
|
||||||
|
.timeline-day {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 5px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.timeline-day:hover, .timeline-day.active { border-bottom-color: var(--accent); }
|
||||||
.timeline-month {
|
.timeline-month {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => `
|
|||||||
let scrollLoadSuppressed = false
|
let scrollLoadSuppressed = false
|
||||||
let activeMonthUpdatePending = false
|
let activeMonthUpdatePending = false
|
||||||
let expandedTimelineYear = ''
|
let expandedTimelineYear = ''
|
||||||
|
let expandedTimelineMonth = ''
|
||||||
|
let selectedTimelineMonth = ''
|
||||||
|
let selectedTimelineDate = ''
|
||||||
const tabPositions = new Map()
|
const tabPositions = new Map()
|
||||||
let lastScrollTop = 0
|
let lastScrollTop = 0
|
||||||
let zoom = 1
|
let zoom = 1
|
||||||
@@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => `
|
|||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
|
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
|
||||||
}
|
}
|
||||||
|
const dateKey = (message) => {
|
||||||
|
const timestamp = Number(message.createTime || 0)
|
||||||
|
if (!timestamp) return 'unknown'
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
if (Number.isNaN(date.getTime())) return 'unknown'
|
||||||
|
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
|
||||||
|
}
|
||||||
const kindOf = (message) => {
|
const kindOf = (message) => {
|
||||||
const data = message.contentData || {}
|
const data = message.contentData || {}
|
||||||
const shareType = String(data.typeVal || '')
|
const shareType = String(data.typeVal || '')
|
||||||
@@ -1119,13 +1172,37 @@ const renderExportScript = (name: string): string => `
|
|||||||
}
|
}
|
||||||
const renderPaymentContent = (data, kind) => {
|
const renderPaymentContent = (data, kind) => {
|
||||||
const isTransfer = kind === 'transfer'
|
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') +
|
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
|
||||||
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
|
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
|
||||||
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
|
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
|
||||||
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' +
|
'<div class="structured-title">' + displayText(title) + '</div>' +
|
||||||
'<div class="structured-description">' +
|
'<div class="structured-description">' + displayText(description) + '</div>' +
|
||||||
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) +
|
(footerBits.filter(Boolean).length
|
||||||
'</div></div>'
|
? '<div class="structured-footer">' + displayText(footerBits.filter(Boolean).join(' · ')) + '</div>'
|
||||||
|
: '') +
|
||||||
|
'</div>'
|
||||||
}
|
}
|
||||||
const renderVoipContent = (data) => {
|
const renderVoipContent = (data) => {
|
||||||
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
|
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
|
||||||
@@ -1317,6 +1394,15 @@ const renderExportScript = (name: string): string => `
|
|||||||
if (months) months.hidden = !expanded
|
if (months) months.hidden = !expanded
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const setExpandedTimelineMonth = (month) => {
|
||||||
|
expandedTimelineMonth = month || ''
|
||||||
|
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||||
|
const expanded = button.dataset.month === expandedTimelineMonth
|
||||||
|
button.setAttribute('aria-expanded', String(expanded))
|
||||||
|
const days = button.nextElementSibling
|
||||||
|
if (days) days.hidden = !expanded
|
||||||
|
})
|
||||||
|
}
|
||||||
const renderTimeline = () => {
|
const renderTimeline = () => {
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
expandedTimelineYear = ''
|
expandedTimelineYear = ''
|
||||||
@@ -1327,18 +1413,36 @@ const renderExportScript = (name: string): string => `
|
|||||||
for (const message of filtered) {
|
for (const message of filtered) {
|
||||||
const key = monthKey(message)
|
const key = monthKey(message)
|
||||||
if (key === 'unknown') continue
|
if (key === 'unknown') continue
|
||||||
groups.set(key, (groups.get(key) || 0) + 1)
|
const date = dateKey(message)
|
||||||
|
const group = groups.get(key) || { total: 0, days: new Map() }
|
||||||
|
group.total += 1
|
||||||
|
if (date !== 'unknown') group.days.set(date, (group.days.get(date) || 0) + 1)
|
||||||
|
groups.set(key, group)
|
||||||
}
|
}
|
||||||
const yearGroups = new Map()
|
const yearGroups = new Map()
|
||||||
for (const [key, total] of groups) {
|
for (const [key, group] of groups) {
|
||||||
const parts = key.split('-')
|
const parts = key.split('-')
|
||||||
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
|
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
|
||||||
yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total })
|
yearGroups.get(parts[0]).push({
|
||||||
|
key,
|
||||||
|
month: Number(parts[1]),
|
||||||
|
total: group.total,
|
||||||
|
days: Array.from(group.days.entries())
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([date, total]) => ({ date, day: Number(date.slice(-2)), total }))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const years = Array.from(yearGroups.keys())
|
const years = Array.from(yearGroups.keys()).sort((left, right) => left.localeCompare(right))
|
||||||
if (!years.includes(expandedTimelineYear)) {
|
if (!years.includes(expandedTimelineYear)) {
|
||||||
expandedTimelineYear = years[years.length - 1] || ''
|
expandedTimelineYear = years[years.length - 1] || ''
|
||||||
}
|
}
|
||||||
|
for (const entries of yearGroups.values()) {
|
||||||
|
entries.sort((left, right) => left.key.localeCompare(right.key))
|
||||||
|
}
|
||||||
|
const allMonthKeys = Array.from(groups.keys()).sort((left, right) => left.localeCompare(right))
|
||||||
|
if (!allMonthKeys.includes(expandedTimelineMonth)) {
|
||||||
|
expandedTimelineMonth = allMonthKeys[allMonthKeys.length - 1] || ''
|
||||||
|
}
|
||||||
let html = ''
|
let html = ''
|
||||||
for (const year of years) {
|
for (const year of years) {
|
||||||
const expanded = year === expandedTimelineYear
|
const expanded = year === expandedTimelineYear
|
||||||
@@ -1348,10 +1452,18 @@ const renderExportScript = (name: string): string => `
|
|||||||
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
|
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
|
||||||
esc(year) + ' 年</button>' +
|
esc(year) + ' 年</button>' +
|
||||||
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
|
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
|
||||||
yearGroups.get(year).map((entry) =>
|
yearGroups.get(year).map((entry) => {
|
||||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
|
const monthExpanded = entry.key === expandedTimelineMonth
|
||||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
|
return '<div class="timeline-month-entry">' +
|
||||||
).join('') + '</div></section>'
|
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) +
|
||||||
|
'" aria-expanded="' + String(monthExpanded) + '">' +
|
||||||
|
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>' +
|
||||||
|
'<div class="timeline-days" data-days-for-month="' + esc(entry.key) + '"' +
|
||||||
|
(monthExpanded ? '' : ' hidden') + '>' + entry.days.map((day) =>
|
||||||
|
'<button class="timeline-day" type="button" data-date="' + esc(day.date) + '">' +
|
||||||
|
'<span>' + esc(day.date.slice(5)) + '</span><small>' + day.total + '</small></button>'
|
||||||
|
).join('') + '</div></div>'
|
||||||
|
}).join('') + '</div></section>'
|
||||||
}
|
}
|
||||||
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
||||||
}
|
}
|
||||||
@@ -1377,16 +1489,27 @@ const renderExportScript = (name: string): string => `
|
|||||||
const activeMessage = atBottom
|
const activeMessage = atBottom
|
||||||
? visible[visible.length - 1] || messages[messages.length - 1]
|
? visible[visible.length - 1] || messages[messages.length - 1]
|
||||||
: anchoredMessage || visible[0] || messages[0]
|
: anchoredMessage || visible[0] || messages[0]
|
||||||
const key = activeMessage && activeMessage.dataset.month
|
const key = selectedTimelineMonth || (selectedTimelineDate
|
||||||
|
? selectedTimelineDate.slice(0, 7)
|
||||||
|
: '') ||
|
||||||
|
(activeMessage && activeMessage.dataset.month)
|
||||||
|
const activeIndex = activeMessage ? Number(activeMessage.dataset.index) : -1
|
||||||
|
const activeDate =
|
||||||
|
selectedTimelineDate ||
|
||||||
|
(activeIndex >= 0 && filtered[activeIndex] ? dateKey(filtered[activeIndex]) : '')
|
||||||
let activeButton
|
let activeButton
|
||||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||||
const active = button.dataset.month === key
|
const active = button.dataset.month === key
|
||||||
button.classList.toggle('active', active)
|
button.classList.toggle('active', active)
|
||||||
if (active) activeButton = button
|
if (active) activeButton = button
|
||||||
})
|
})
|
||||||
|
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||||
|
button.classList.toggle('active', button.dataset.date === activeDate)
|
||||||
|
})
|
||||||
if (!activeButton) return
|
if (!activeButton) return
|
||||||
const activeYear = key.split('-')[0]
|
const activeYear = key.split('-')[0]
|
||||||
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
||||||
|
if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key)
|
||||||
const timelineBounds = timeline.getBoundingClientRect()
|
const timelineBounds = timeline.getBoundingClientRect()
|
||||||
const buttonBounds = activeButton.getBoundingClientRect()
|
const buttonBounds = activeButton.getBoundingClientRect()
|
||||||
if (buttonBounds.top < timelineBounds.top) {
|
if (buttonBounds.top < timelineBounds.top) {
|
||||||
@@ -1535,6 +1658,8 @@ const renderExportScript = (name: string): string => `
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const applyFilters = (restorePosition = false) => {
|
const applyFilters = (restorePosition = false) => {
|
||||||
|
selectedTimelineMonth = ''
|
||||||
|
selectedTimelineDate = ''
|
||||||
filtered = matchingMessages()
|
filtered = matchingMessages()
|
||||||
renderTimeline()
|
renderTimeline()
|
||||||
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
||||||
@@ -1572,18 +1697,32 @@ const renderExportScript = (name: string): string => `
|
|||||||
target.classList.add('located')
|
target.classList.add('located')
|
||||||
window.setTimeout(() => target.classList.remove('located'), 1600)
|
window.setTimeout(() => target.classList.remove('located'), 1600)
|
||||||
}
|
}
|
||||||
const jumpToMonth = (key) => {
|
const selectTimelineMonth = (key) => {
|
||||||
const index = filtered.findIndex((message) => monthKey(message) === key)
|
if (!filtered.some((message) => monthKey(message) === key)) return
|
||||||
|
selectedTimelineMonth = key
|
||||||
|
selectedTimelineDate = ''
|
||||||
|
setExpandedTimelineYear(key.split('-')[0])
|
||||||
|
setExpandedTimelineMonth(key)
|
||||||
|
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||||
|
button.classList.toggle('active', button.dataset.month === key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const jumpToDate = (key) => {
|
||||||
|
const index = filtered.findIndex((message) => dateKey(message) === key)
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
|
selectedTimelineMonth = ''
|
||||||
|
selectedTimelineDate = key
|
||||||
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
|
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
|
||||||
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
||||||
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
||||||
renderWindow()
|
renderWindow()
|
||||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
const target = list.querySelector('.message[data-index="' + index + '"]')
|
||||||
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
||||||
setExpandedTimelineYear(key.split('-')[0])
|
const month = key.slice(0, 7)
|
||||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
setExpandedTimelineYear(month.slice(0, 4))
|
||||||
button.classList.toggle('active', button.dataset.month === key)
|
setExpandedTimelineMonth(month)
|
||||||
|
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||||
|
button.classList.toggle('active', button.dataset.date === key)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const slideWindow = (direction) => {
|
const slideWindow = (direction) => {
|
||||||
@@ -1620,6 +1759,10 @@ const renderExportScript = (name: string): string => `
|
|||||||
const nearTop = currentTop < 180
|
const nearTop = currentTop < 180
|
||||||
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
||||||
lastScrollTop = currentTop
|
lastScrollTop = currentTop
|
||||||
|
if (!scrollLoadSuppressed) {
|
||||||
|
selectedTimelineMonth = ''
|
||||||
|
selectedTimelineDate = ''
|
||||||
|
}
|
||||||
scheduleActiveMonthUpdate()
|
scheduleActiveMonthUpdate()
|
||||||
if (scrollLoadSuppressed) return
|
if (scrollLoadSuppressed) return
|
||||||
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
||||||
@@ -1676,7 +1819,12 @@ const renderExportScript = (name: string): string => `
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const button = event.target.closest('[data-month]')
|
const button = event.target.closest('[data-month]')
|
||||||
if (button) jumpToMonth(button.dataset.month)
|
if (button) {
|
||||||
|
selectTimelineMonth(button.dataset.month)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dayButton = event.target.closest('[data-date]')
|
||||||
|
if (dayButton) jumpToDate(dayButton.dataset.date)
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
|
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
|
||||||
|
|||||||
+176
-83
@@ -25,11 +25,13 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
|
|||||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||||
import { imageFileQuality } from '../shared/image-quality'
|
import { imageFileQuality } from '../shared/image-quality'
|
||||||
import { resolveMemberName } from '../shared/member-names'
|
import { resolveMemberName } from '../shared/member-names'
|
||||||
|
import { FavoritesService } from './favorites-service'
|
||||||
|
import { filesystemSafeName } from '../shared/contact-name'
|
||||||
|
|
||||||
const jobs = new Set<string>()
|
const jobs = new Set<string>()
|
||||||
const activeArchives = new Map<string, Archiver>()
|
const activeArchives = new Map<string, Archiver>()
|
||||||
const safeFilePart = (value: string): string =>
|
const safeFilePart = (value: string, fallback = '聊天档案'): string =>
|
||||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
filesystemSafeName(value, fallback)
|
||||||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await fs.chmod(destination, 0o644)
|
await fs.chmod(destination, 0o644)
|
||||||
@@ -643,6 +645,7 @@ const kindOf = (message: Message): ExportMessageKind => {
|
|||||||
return 'text'
|
return 'text'
|
||||||
}
|
}
|
||||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
|
const csvBom = '\uFEFF'
|
||||||
|
|
||||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||||
if (format === 'html') return renderExportPage(name)
|
if (format === 'html') return renderExportPage(name)
|
||||||
@@ -650,7 +653,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
|||||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||||
if (format === 'markdown')
|
if (format === 'markdown')
|
||||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||||||
return [
|
return csvBom + [
|
||||||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||||
...messages.map((m) =>
|
...messages.map((m) =>
|
||||||
[
|
[
|
||||||
@@ -664,7 +667,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
|||||||
.map(csv)
|
.map(csv)
|
||||||
.join(',')
|
.join(',')
|
||||||
)
|
)
|
||||||
].join('\n')
|
].join('\r\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SingleExportOptions {
|
interface SingleExportOptions {
|
||||||
@@ -681,6 +684,8 @@ interface AllExportManifestEntry {
|
|||||||
type: ExportTarget['type']
|
type: ExportTarget['type']
|
||||||
folder: string
|
folder: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
|
status: 'completed' | 'failed'
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeAllExportManifest = async (
|
const writeAllExportManifest = async (
|
||||||
@@ -849,6 +854,28 @@ async function runSingleExport(
|
|||||||
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
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
|
const messages = messageEntries
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
||||||
@@ -941,10 +968,7 @@ async function runSingleExport(
|
|||||||
request.format === 'html'
|
request.format === 'html'
|
||||||
? options.outputFolderName || safeFilePart(request.outputName)
|
? options.outputFolderName || safeFilePart(request.outputName)
|
||||||
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||||
const root =
|
const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
|
||||||
options.outputRoot ||
|
|
||||||
request.outputDirectory ||
|
|
||||||
(await resolveDefaultExportRoot(outputFolder))
|
|
||||||
await fs.mkdir(root, { recursive: true })
|
await fs.mkdir(root, { recursive: true })
|
||||||
const outputDir = join(root, outputFolder)
|
const outputDir = join(root, outputFolder)
|
||||||
const outputPath =
|
const outputPath =
|
||||||
@@ -1089,7 +1113,10 @@ async function runSingleExport(
|
|||||||
}
|
}
|
||||||
const voiceService =
|
const voiceService =
|
||||||
request.includeMedia && chat.getChatDb()
|
request.includeMedia && chat.getChatDb()
|
||||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
? new VoiceService(
|
||||||
|
chat.getChatDb()!.getWcdb4Client(),
|
||||||
|
chat.getChatDb()!.getWcdb4Client().getAccountRoot()
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
||||||
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
||||||
@@ -1236,7 +1263,21 @@ async function runSingleExport(
|
|||||||
markResourceExists(voiceUrl)
|
markResourceExists(voiceUrl)
|
||||||
}
|
}
|
||||||
message.voiceDataUrl = voiceUrl
|
message.voiceDataUrl = voiceUrl
|
||||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
// 读取 WAV 头中的采样率,避免按错误采样率计算时长(Silk 解码为 16kHz)
|
||||||
|
const wavSampleRate =
|
||||||
|
audioBuffer.length >= 44 ? audioBuffer.readUInt32LE(24) : 16000
|
||||||
|
const wavChannels =
|
||||||
|
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
|
||||||
|
const pcmBytes = Math.max(0, audioBuffer.length - 44)
|
||||||
|
// 口径统一:消息解析阶段已从 <voicemsg voicelength> 拿到微信的原始秒数(带小数),
|
||||||
|
// 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算——
|
||||||
|
// 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。
|
||||||
|
if (message.voiceDuration == null) {
|
||||||
|
message.voiceDuration = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
keepMediaError(
|
keepMediaError(
|
||||||
request,
|
request,
|
||||||
@@ -1478,10 +1519,13 @@ async function runSingleExport(
|
|||||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||||
const decoded = result.data ? decodeDataUrl(result.data) : null
|
const decoded = result.data
|
||||||
const stickerExtension = decoded ? detectAssetExtension(decoded.buffer) : null
|
? decodeDataUrl(result.data)
|
||||||
if (decoded && stickerExtension) {
|
: stickerSource
|
||||||
const name = `sticker_${bufferHashPart(decoded.buffer)}.${stickerExtension}`
|
? await readAvatarAsset(stickerSource)
|
||||||
|
: null
|
||||||
|
if (decoded) {
|
||||||
|
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
||||||
const mediaUrl = `media/${name}`
|
const mediaUrl = `media/${name}`
|
||||||
if (!(await resourceExists(mediaUrl))) {
|
if (!(await resourceExists(mediaUrl))) {
|
||||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
@@ -1626,6 +1670,8 @@ async function runAllExport(
|
|||||||
let outputDir = ''
|
let outputDir = ''
|
||||||
const manifest: AllExportManifestEntry[] = []
|
const manifest: AllExportManifestEntry[] = []
|
||||||
let totalMessages = 0
|
let totalMessages = 0
|
||||||
|
let failedCount = 0
|
||||||
|
const failureMessages: string[] = []
|
||||||
try {
|
try {
|
||||||
const targets = [...(request.targets || [])].sort((left, right) =>
|
const targets = [...(request.targets || [])].sort((left, right) =>
|
||||||
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
||||||
@@ -1658,80 +1704,121 @@ async function runAllExport(
|
|||||||
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
||||||
const categoryDir = join(outputDir, categoryName)
|
const categoryDir = join(outputDir, categoryName)
|
||||||
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
||||||
await fs.mkdir(categoryDir, { recursive: true })
|
const folder = `${categoryName}/${folderName}`
|
||||||
const conversationOutputRoot =
|
let result: ExportResult
|
||||||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
try {
|
||||||
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
await fs.mkdir(categoryDir, { recursive: true })
|
||||||
send({
|
const conversationOutputRoot =
|
||||||
jobId: request.jobId,
|
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||||||
phase: 'reading',
|
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
||||||
processed: 0,
|
send({
|
||||||
percent: basePercent,
|
jobId: request.jobId,
|
||||||
currentTargetIndex: targetIndex + 1,
|
phase: 'reading',
|
||||||
currentTargetCount: targets.length,
|
processed: 0,
|
||||||
currentTargetName: target.name,
|
percent: basePercent,
|
||||||
currentTargetType: target.type
|
currentTargetIndex: targetIndex + 1,
|
||||||
})
|
currentTargetCount: targets.length,
|
||||||
|
currentTargetName: target.name,
|
||||||
|
currentTargetType: target.type
|
||||||
|
})
|
||||||
|
|
||||||
const result = await runSingleExport(
|
result = await runSingleExport(
|
||||||
{
|
{
|
||||||
...request,
|
...request,
|
||||||
targets: [target],
|
targets: [target],
|
||||||
outputName: target.name,
|
outputName: target.name,
|
||||||
zip: false
|
zip: false
|
||||||
},
|
},
|
||||||
win,
|
win,
|
||||||
voiceRecognition,
|
voiceRecognition,
|
||||||
{
|
{
|
||||||
outputRoot: conversationOutputRoot,
|
outputRoot: conversationOutputRoot,
|
||||||
outputFolderName: request.format === 'html' ? folderName : undefined,
|
outputFolderName: request.format === 'html' ? folderName : undefined,
|
||||||
manageJob: false,
|
manageJob: false,
|
||||||
selfInfo,
|
selfInfo,
|
||||||
sendProgress: (childProgress) => {
|
sendProgress: (childProgress) => {
|
||||||
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
||||||
const percent = Math.min(
|
const percent = Math.min(
|
||||||
99,
|
99,
|
||||||
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
||||||
)
|
)
|
||||||
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const progressKey = `${targetIndex}:${phase}:${percent}`
|
const progressKey = `${targetIndex}:${phase}:${percent}`
|
||||||
const terminal = phase === 'failed' || phase === 'cancelled'
|
const terminal = phase === 'failed' || phase === 'cancelled'
|
||||||
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
||||||
lastProgressKey = progressKey
|
lastProgressKey = progressKey
|
||||||
lastProgressAt = now
|
lastProgressAt = now
|
||||||
send({
|
send({
|
||||||
...childProgress,
|
...childProgress,
|
||||||
jobId: request.jobId,
|
jobId: request.jobId,
|
||||||
phase,
|
phase,
|
||||||
percent,
|
percent,
|
||||||
outputPath: undefined,
|
outputPath: undefined,
|
||||||
currentTargetIndex: targetIndex + 1,
|
currentTargetIndex: targetIndex + 1,
|
||||||
currentTargetCount: targets.length,
|
currentTargetCount: targets.length,
|
||||||
currentTargetName: target.name,
|
currentTargetName: target.name,
|
||||||
currentTargetType: target.type
|
currentTargetType: target.type
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
} catch (error) {
|
||||||
if (!result.success) {
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
if (result.error === '已取消') throw new Error('已取消')
|
if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||||
throw new Error(`${target.name}:${result.error || '导出失败'}`)
|
result = { success: false, error: message }
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageCount = result.messageCount || 0
|
if (!result.success) {
|
||||||
totalMessages += messageCount
|
if (result.error === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||||
manifest.push({
|
const error = `${target.name}:${result.error || '导出失败'}`
|
||||||
id: target.userMd5,
|
failedCount += 1
|
||||||
name: target.name,
|
failureMessages.push(error)
|
||||||
type: target.type,
|
manifest.push({
|
||||||
folder: `${categoryName}/${folderName}`,
|
id: target.userMd5,
|
||||||
messageCount
|
name: target.name,
|
||||||
})
|
type: target.type,
|
||||||
|
folder,
|
||||||
|
messageCount: 0,
|
||||||
|
status: 'failed',
|
||||||
|
error: result.error || '导出失败'
|
||||||
|
})
|
||||||
|
const failedPercent = Math.min(
|
||||||
|
99,
|
||||||
|
Math.floor(((targetIndex + 1) / targets.length) * 100)
|
||||||
|
)
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'failed',
|
||||||
|
processed: totalMessages,
|
||||||
|
total: totalMessages,
|
||||||
|
percent: failedPercent,
|
||||||
|
error,
|
||||||
|
currentTargetIndex: targetIndex + 1,
|
||||||
|
currentTargetCount: targets.length,
|
||||||
|
currentTargetName: target.name,
|
||||||
|
currentTargetType: target.type
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const messageCount = result.messageCount || 0
|
||||||
|
totalMessages += messageCount
|
||||||
|
manifest.push({
|
||||||
|
id: target.userMd5,
|
||||||
|
name: target.name,
|
||||||
|
type: target.type,
|
||||||
|
folder,
|
||||||
|
messageCount,
|
||||||
|
status: 'completed'
|
||||||
|
})
|
||||||
|
}
|
||||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
|
const finalStatus = failedCount ? 'failed' : 'completed'
|
||||||
|
const finalError = failedCount
|
||||||
|
? `全部导出完成:成功 ${targets.length - failedCount} 个,失败 ${failedCount} 个。${failureMessages.join(';')}`
|
||||||
|
: undefined
|
||||||
|
await writeAllExportManifest(outputDir, manifest, totalMessages, finalStatus, finalError)
|
||||||
|
|
||||||
let completedPath = outputDir
|
let completedPath = outputDir
|
||||||
if (request.zip) {
|
if (request.zip) {
|
||||||
@@ -1750,17 +1837,23 @@ async function runAllExport(
|
|||||||
|
|
||||||
send({
|
send({
|
||||||
jobId: request.jobId,
|
jobId: request.jobId,
|
||||||
phase: 'completed',
|
phase: failedCount ? 'failed' : 'completed',
|
||||||
processed: totalMessages,
|
processed: totalMessages,
|
||||||
total: totalMessages,
|
total: totalMessages,
|
||||||
percent: 100,
|
percent: 100,
|
||||||
|
error: finalError,
|
||||||
outputPath: completedPath,
|
outputPath: completedPath,
|
||||||
currentTargetIndex: targets.length,
|
currentTargetIndex: targets.length,
|
||||||
currentTargetCount: targets.length,
|
currentTargetCount: targets.length,
|
||||||
currentTargetName: targets.at(-1)?.name,
|
currentTargetName: targets.at(-1)?.name,
|
||||||
currentTargetType: targets.at(-1)?.type
|
currentTargetType: targets.at(-1)?.type
|
||||||
})
|
})
|
||||||
return { success: true, outputPath: completedPath, messageCount: totalMessages }
|
return {
|
||||||
|
success: failedCount === 0,
|
||||||
|
outputPath: completedPath,
|
||||||
|
messageCount: totalMessages,
|
||||||
|
...(finalError ? { error: finalError } : {})
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
||||||
|
|||||||
@@ -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 '[收藏]'
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { appendFileSync } from 'node:fs'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import {
|
import {
|
||||||
GroupReportExportRequest,
|
GroupReportExportRequest,
|
||||||
GroupReportExportResult,
|
GroupReportExportResult,
|
||||||
@@ -10,11 +13,16 @@ import {
|
|||||||
GroupReportRenderSnapshotExportRequest,
|
GroupReportRenderSnapshotExportRequest,
|
||||||
ReportHeat,
|
ReportHeat,
|
||||||
ReportSectionMeta,
|
ReportSectionMeta,
|
||||||
|
buildReportAvatarAliasIndex,
|
||||||
|
mergeReportAvatars,
|
||||||
selectHeroParticipantNames
|
selectHeroParticipantNames
|
||||||
} from '../shared/group-report'
|
} from '../shared/group-report'
|
||||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||||
import { imageInsightService } from './services/image-insight-service'
|
import { imageInsightService } from './services/image-insight-service'
|
||||||
import { getReportTemplate } from '../shared/report-templates'
|
import { getReportTemplate } from '../shared/report-templates'
|
||||||
|
import type { ReportTemplateRef } from '../shared/report-template-package'
|
||||||
|
import { injectReportTemplateFragmentContract } from '../shared/report-template-fragment-contract'
|
||||||
|
import { reportTemplateService, validateReportTemplateHtml } from './report-template-service'
|
||||||
|
|
||||||
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
||||||
v1: 'mobile_daily_report_v1.html',
|
v1: 'mobile_daily_report_v1.html',
|
||||||
@@ -33,6 +41,40 @@ const templatePath = (templateId?: string): string => {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveTemplateSelection = async (templateId?: string, templateRef?: ReportTemplateRef) => {
|
||||||
|
if (!templateRef) {
|
||||||
|
const definition = getReportTemplate(templateId)
|
||||||
|
return {
|
||||||
|
definition,
|
||||||
|
entryPath: templatePath(templateId),
|
||||||
|
captureMaxHeight: 20000,
|
||||||
|
source: 'builtin' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const installed = await reportTemplateService.resolve(templateRef)
|
||||||
|
return {
|
||||||
|
definition: {
|
||||||
|
id: installed.id,
|
||||||
|
order: 999,
|
||||||
|
platform: 'default' as const,
|
||||||
|
label: installed.name,
|
||||||
|
name: installed.name,
|
||||||
|
tagline: `${installed.author} · ${installed.version}`,
|
||||||
|
fileLabel: installed.name,
|
||||||
|
cssClass: `template-external-${installed.id.replace(/[^a-z0-9-]/gi, '-')}`,
|
||||||
|
resourceFile: installed.entryPath,
|
||||||
|
captureWidth: installed.capture.width,
|
||||||
|
maxCaptureWidth: installed.capture.maxWidth
|
||||||
|
},
|
||||||
|
entryPath: installed.entryPath,
|
||||||
|
captureMaxHeight: installed.capture.maxHeight,
|
||||||
|
source: 'installed' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTemplate = async (request: GroupReportExportRequest) =>
|
||||||
|
resolveTemplateSelection(request.templateId, request.templateRef)
|
||||||
|
|
||||||
const escapeHtml = (value: unknown): string =>
|
const escapeHtml = (value: unknown): string =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -53,55 +95,161 @@ const hashName = (name: string): number => {
|
|||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackAvatar = (name: string): string => {
|
type RenderedAvatar = { source: string; fallback: boolean }
|
||||||
|
|
||||||
|
const fallbackAvatar = (name: string): RenderedAvatar => {
|
||||||
const hue = hashName(name) % 360
|
const hue = hashName(name) % 360
|
||||||
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
return {
|
||||||
}
|
source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
|
||||||
|
fallback: true
|
||||||
const imageMimeType = (contentType: string | null, source: string): string => {
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只认真实图片字节。
|
||||||
|
*
|
||||||
|
* `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。
|
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||||
* - 没传 talker → 跳过(向后兼容)
|
* - 没传 talker → 跳过(向后兼容)
|
||||||
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
|
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
|
||||||
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
|
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
|
||||||
* - 同名取首条(P2 风险:群里两人同名)
|
* - 同名取首条(P2 风险:群里两人同名)
|
||||||
|
*
|
||||||
|
* **必须按多个别名建索引**:报告里的显示名取决于 `memberNameMode`
|
||||||
|
* (默认 `groupNickname` = 群昵称),而快照的 `nickname` 字段是
|
||||||
|
* `wechatNickname || groupNickname || username`(见 `normalizeGroupMembers`)。
|
||||||
|
* 一个成员同时有微信昵称与群昵称且两者不同时,只按 `nickname` 建索引就会**全部对不上**,
|
||||||
|
* 于是头像 enrichment 静默失效 —— 这正是原先只用单一索引键时的问题。
|
||||||
*/
|
*/
|
||||||
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
|
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
|
||||||
if (!metadata.talker) return
|
if (!metadata.talker) return
|
||||||
@@ -120,22 +268,16 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = new Map<string, string>()
|
// 每个成员的所有可用显示名都指向同一个头像 URL;先到先得,避免同名互相覆盖。
|
||||||
for (const member of snapshot.members) {
|
// 只按 `member.nickname` 建索引会在"微信昵称 ≠ 群昵称"时全部对不上 —— 见 shared 里的注释。
|
||||||
if (member.nickname && member.avatar && !index.has(member.nickname)) {
|
const index = buildReportAvatarAliasIndex(snapshot.members)
|
||||||
index.set(member.nickname, member.avatar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata.avatars = metadata.avatars ?? {}
|
metadata.avatars = metadata.avatars ?? {}
|
||||||
for (const [name, url] of index) {
|
const filled = mergeReportAvatars(metadata.avatars, index)
|
||||||
if (metadata.avatars[name]) continue
|
|
||||||
metadata.avatars[name] = url
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata.warnings = metadata.warnings ?? []
|
metadata.warnings = metadata.warnings ?? []
|
||||||
metadata.warnings.push(
|
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 =>
|
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||||
html.replaceAll(`{{${key}}}`, value)
|
html.replaceAll(`{{${key}}}`, value)
|
||||||
|
|
||||||
|
const addReportCsp = (html: string, allowFileImages = true): string =>
|
||||||
|
html.replace(
|
||||||
|
/<head(\s[^>]*)?>/i,
|
||||||
|
(head) =>
|
||||||
|
`${head}<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:${allowFileImages ? ' file:' : ''}; style-src 'unsafe-inline'; font-src 'none'; base-uri 'none'; form-action 'none'">`
|
||||||
|
)
|
||||||
|
|
||||||
|
const inlineTemplateAssets = async (html: string, templateRoot: string): Promise<string> => {
|
||||||
|
const assetDataUrl = async (relative: string): Promise<string | null> => {
|
||||||
|
const assetPath = path.resolve(templateRoot, relative)
|
||||||
|
if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) return null
|
||||||
|
const extension = path.extname(assetPath).toLowerCase()
|
||||||
|
const mime =
|
||||||
|
extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||||
|
const data = (await fs.readFile(assetPath)).toString('base64')
|
||||||
|
return `data:${mime};base64,${data}`
|
||||||
|
}
|
||||||
|
let result = html
|
||||||
|
for (const match of html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)) {
|
||||||
|
const dataUrl = await assetDataUrl(match[2])
|
||||||
|
if (dataUrl) result = result.replace(match[0], `src="${dataUrl}"`)
|
||||||
|
}
|
||||||
|
for (const match of html.matchAll(/\burl\(\s*(['"]?)(assets\/[A-Za-z0-9._/-]+)\1\s*\)/g)) {
|
||||||
|
const dataUrl = await assetDataUrl(match[2])
|
||||||
|
if (dataUrl) result = result.replace(match[0], `url("${dataUrl}")`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordBlockedTemplateRequest = (
|
||||||
|
details: Electron.OnBeforeRequestListenerDetails,
|
||||||
|
reason: string
|
||||||
|
): void => {
|
||||||
|
// 仅测试入口设置该路径,用于保留运行时拦截证据;生产默认不记录请求明细。
|
||||||
|
const logPath = process.env.TRACEMEMO_TEMPLATE_SECURITY_LOG
|
||||||
|
if (!logPath) return
|
||||||
|
try {
|
||||||
|
appendFileSync(
|
||||||
|
logPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
url: details.url,
|
||||||
|
method: details.method,
|
||||||
|
resourceType: details.resourceType,
|
||||||
|
reason
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[GroupReport] failed to record template security block:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sectionMeta = (
|
const sectionMeta = (
|
||||||
request: GroupReportExportRequest,
|
request: GroupReportExportRequest,
|
||||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||||
@@ -170,7 +364,8 @@ const overflowNote = (
|
|||||||
|
|
||||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||||
const { report, metadata } = request
|
const { report, metadata } = request
|
||||||
const template = getReportTemplate(request.templateId)
|
const resolvedTemplate = await resolveTemplate(request)
|
||||||
|
const template = resolvedTemplate.definition
|
||||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||||
@@ -181,24 +376,42 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||||
|
|
||||||
const avatars = new Map<string, string>()
|
// 有界并发 + 单条重试:几十个头像同时打同一个 CDN 会被限流,瞬时失败会让一个人
|
||||||
await Promise.all(
|
// 在整份报告里永久退化成首字占位(实测同一天三次生成:0% / 0% / 32% 失败)。
|
||||||
Array.from(avatarNames).map(async (name) => {
|
const renderedAvatars = await mapWithConcurrency(
|
||||||
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
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 heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||||
const heroAvatars = heroNames
|
const heroAvatars = heroNames.map((name) => renderAvatar(name, 'hero', '', name)).join('')
|
||||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
|
||||||
.join('')
|
|
||||||
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
||||||
|
|
||||||
const topicCards = report.topics
|
const topicCards = report.topics
|
||||||
.map(
|
.map(
|
||||||
(topic) => `<div class="card topic-card">
|
(topic) => `<div class="card topic-card tm-fragment tm-topic-card">
|
||||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
<div class="topic-title-row tm-topic-card__title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||||
<p>${escapeHtml(topic.summary)}</p>
|
<p>${escapeHtml(topic.summary)}</p>
|
||||||
${
|
${
|
||||||
@@ -237,15 +450,15 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!imageUrl) return ''
|
if (!imageUrl) return ''
|
||||||
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
return `<div class="topic-inline-image"><img src="${escapeHtml(imageUrl)}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||||
})()
|
})()
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
<div class="participants">${topic.participants
|
<div class="participants tm-topic-card__participants">${topic.participants
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(
|
||||||
(name) =>
|
(name) =>
|
||||||
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
`<span class="person-chip tm-participant">${renderAvatar(name, 'participant')}<b class="tm-participant__name">${escapeHtml(name)}</b></span>`
|
||||||
)
|
)
|
||||||
.join('')}</div>
|
.join('')}</div>
|
||||||
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||||
@@ -262,10 +475,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const importantMessages = report.importantMessages
|
const importantMessages = report.importantMessages
|
||||||
.map(
|
.map(
|
||||||
(message) => `<div class="important-card">
|
(message) => `<div class="important-card tm-fragment tm-message tm-message--important">
|
||||||
<img class="avatar" src="${avatar(message.sender)}" alt="">
|
${renderAvatar(message.sender, 'message', 'avatar')}
|
||||||
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
|
<div class="important-body tm-message__body"><div class="important-meta tm-message__meta"><b class="tm-message__author">${escapeHtml(message.sender)}</b><span class="tm-message__time">${escapeHtml(message.time)}</span></div>
|
||||||
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
|
<div class="important-text tm-message__text">${escapeHtml(message.content)}</div><div class="important-note tm-message__note">${escapeHtml(message.note)}</div></div>
|
||||||
</div>`
|
</div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
@@ -273,12 +486,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
const quoteBlocks = report.quotes
|
const quoteBlocks = report.quotes
|
||||||
.map(
|
.map(
|
||||||
(quote) =>
|
(quote) =>
|
||||||
`<div class="chat-block">${quote.messages
|
`<div class="chat-block tm-fragment tm-quote">${quote.messages
|
||||||
.map(
|
.map(
|
||||||
(
|
(
|
||||||
message
|
message
|
||||||
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
|
) => `<div class="chat-msg tm-message tm-message--quote">${renderAvatar(message.sender, 'message', 'chat-avatar')}<div class="tm-message__body">
|
||||||
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
|
<div class="chat-name tm-message__author">${escapeHtml(message.sender)}</div><div class="chat-bubble tm-message__text">${escapeHtml(message.content)}</div>
|
||||||
</div></div>`
|
</div></div>`
|
||||||
)
|
)
|
||||||
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
||||||
@@ -287,7 +500,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const todoCards = (report.todos || [])
|
const todoCards = (report.todos || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="action-card todo-card">
|
(item) => `<div class="action-card todo-card tm-fragment tm-todo-card">
|
||||||
<b>${escapeHtml(item.task)}</b>
|
<b>${escapeHtml(item.task)}</b>
|
||||||
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||||
@@ -297,7 +510,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const unresolvedCards = (report.unresolved || [])
|
const unresolvedCards = (report.unresolved || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="action-card unresolved-card">
|
(item) => `<div class="action-card unresolved-card tm-fragment tm-unresolved-card">
|
||||||
<b>${escapeHtml(item.question)}</b>
|
<b>${escapeHtml(item.question)}</b>
|
||||||
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
<div class="action-note">${escapeHtml(item.note)}</div>
|
<div class="action-note">${escapeHtml(item.note)}</div>
|
||||||
@@ -324,7 +537,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const reversalCards = (report.reversals || [])
|
const reversalCards = (report.reversals || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="qa-card">
|
(item) => `<div class="qa-card tm-fragment tm-reversal-card">
|
||||||
<b>${escapeHtml(item.topic)}</b>
|
<b>${escapeHtml(item.topic)}</b>
|
||||||
<div>最初:${escapeHtml(item.initialView)}</div>
|
<div>最初:${escapeHtml(item.initialView)}</div>
|
||||||
<div>后来:${escapeHtml(item.finalView)}</div>
|
<div>后来:${escapeHtml(item.finalView)}</div>
|
||||||
@@ -349,7 +562,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="vision-card">
|
(item) => `<div class="vision-card">
|
||||||
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
<img class="vision-image" src="${escapeHtml(item.imageUrl)}" alt="AI 识别的图片">
|
||||||
<div class="vision-body">
|
<div class="vision-body">
|
||||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||||
<div class="vision-description">${escapeHtml(item.description)}</div>
|
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||||
@@ -363,7 +576,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const voiceCards = (report.media?.voiceHighlights || [])
|
const voiceCards = (report.media?.voiceHighlights || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="qa-card">
|
(item) => `<div class="qa-card tm-fragment tm-voice-card">
|
||||||
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
||||||
<div>${escapeHtml(item.note)}</div>
|
<div>${escapeHtml(item.note)}</div>
|
||||||
</div>`
|
</div>`
|
||||||
@@ -372,10 +585,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
||||||
.map(
|
.map(
|
||||||
(item, index) => `<div class="rank">
|
(item, index) => `<div class="rank tm-fragment tm-ranking-item">
|
||||||
<img src="${avatar(item.sender)}" alt="">
|
${renderAvatar(item.sender, 'ranking')}
|
||||||
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
||||||
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
<span>${item.count} 条 · ${Math.round(item.durationSec)} 秒</span>
|
||||||
</div>`
|
</div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
@@ -394,7 +607,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(
|
||||||
(speaker, index) =>
|
(speaker, index) =>
|
||||||
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
`<div class="rank tm-fragment tm-ranking-item">${renderAvatar(speaker.name, 'ranking')}<b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
@@ -424,7 +637,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
const qaCards = report.qa
|
const qaCards = report.qa
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) =>
|
||||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
`<div class="qa-card tm-fragment tm-qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
@@ -441,7 +654,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
unresolvedCount: report.unresolved.length
|
unresolvedCount: report.unresolved.length
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||||
|
if (resolvedTemplate.source === 'installed') {
|
||||||
|
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||||
|
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||||
|
}
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
TEMPLATE_CLASS: template.cssClass,
|
TEMPLATE_CLASS: template.cssClass,
|
||||||
TEMPLATE_LABEL: escapeHtml(template.label),
|
TEMPLATE_LABEL: escapeHtml(template.label),
|
||||||
@@ -547,14 +764,22 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||||
return html
|
return addReportCsp(
|
||||||
|
injectReportTemplateFragmentContract(html),
|
||||||
|
resolvedTemplate.source === 'builtin'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderReportSnapshotHtml = async (
|
const renderReportSnapshotHtml = async (
|
||||||
request: GroupReportRenderSnapshotExportRequest
|
request: GroupReportRenderSnapshotExportRequest
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const template = getReportTemplate(request.templateId)
|
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
const template = resolvedTemplate.definition
|
||||||
|
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||||
|
if (resolvedTemplate.source === 'installed') {
|
||||||
|
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||||
|
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||||
|
}
|
||||||
const values = {
|
const values = {
|
||||||
...request.snapshot.values,
|
...request.snapshot.values,
|
||||||
TEMPLATE_CLASS: template.cssClass,
|
TEMPLATE_CLASS: template.cssClass,
|
||||||
@@ -565,7 +790,10 @@ const renderReportSnapshotHtml = async (
|
|||||||
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
|
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||||
return html.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
|
return addReportCsp(
|
||||||
|
html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''),
|
||||||
|
resolvedTemplate.source === 'builtin'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extractGroupReportRenderSnapshot = async (
|
export const extractGroupReportRenderSnapshot = async (
|
||||||
@@ -777,23 +1005,68 @@ export const extractGroupReportRenderSnapshot = async (
|
|||||||
const captureFullPage = async (
|
const captureFullPage = async (
|
||||||
htmlPath: string,
|
htmlPath: string,
|
||||||
pngPath: string,
|
pngPath: string,
|
||||||
templateId?: string
|
templateId?: string,
|
||||||
|
templateOverride?: {
|
||||||
|
captureWidth: number
|
||||||
|
maxCaptureWidth: number
|
||||||
|
maxCaptureHeight?: number
|
||||||
|
}
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const template = getReportTemplate(templateId)
|
const template = templateOverride || getReportTemplate(templateId)
|
||||||
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
|
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
|
||||||
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
|
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
|
||||||
|
const maxCaptureHeight = templateOverride?.maxCaptureHeight || 20000
|
||||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||||
|
const reportSessionPartition = `report-template-${crypto.randomUUID()}`
|
||||||
const reportWindow = new BrowserWindow({
|
const reportWindow = new BrowserWindow({
|
||||||
show: false,
|
show: false,
|
||||||
|
opacity: 0,
|
||||||
|
skipTaskbar: true,
|
||||||
width: captureWidth,
|
width: captureWidth,
|
||||||
height: 800,
|
height: 800,
|
||||||
frame: false,
|
frame: false,
|
||||||
backgroundColor: '#f3f5f7',
|
backgroundColor: '#f3f5f7',
|
||||||
webPreferences: { sandbox: true }
|
webPreferences: {
|
||||||
|
sandbox: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
partition: reportSessionPartition
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
const allowedDocumentPath = path.resolve(htmlPath)
|
||||||
|
const requestHandler = (
|
||||||
|
details: Electron.OnBeforeRequestListenerDetails,
|
||||||
|
callback: (response: { cancel: boolean }) => void
|
||||||
|
): void => {
|
||||||
|
if (details.url.startsWith('file:')) {
|
||||||
|
try {
|
||||||
|
const requestedPath = path.resolve(fileURLToPath(details.url))
|
||||||
|
if (requestedPath === allowedDocumentPath) {
|
||||||
|
callback({ cancel: false })
|
||||||
|
} else {
|
||||||
|
recordBlockedTemplateRequest(details, 'file-path-not-allowed')
|
||||||
|
callback({ cancel: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
recordBlockedTemplateRequest(details, 'invalid-file-url')
|
||||||
|
callback({ cancel: true })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordBlockedTemplateRequest(details, 'scheme-not-allowed')
|
||||||
|
callback({ cancel: true })
|
||||||
|
}
|
||||||
|
reportWindow.webContents.session.webRequest.onBeforeRequest(
|
||||||
|
{ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*', 'file://*/*'] },
|
||||||
|
requestHandler
|
||||||
|
)
|
||||||
|
reportWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
|
reportWindow.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const readyToShow = new Promise<void>((resolve) => reportWindow.once('ready-to-show', resolve))
|
||||||
await reportWindow.loadFile(htmlPath)
|
await reportWindow.loadFile(htmlPath)
|
||||||
|
await readyToShow
|
||||||
console.log('[GroupReport] capture loaded html')
|
console.log('[GroupReport] capture loaded html')
|
||||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||||
document.fonts.ready,
|
document.fonts.ready,
|
||||||
@@ -808,17 +1081,29 @@ const captureFullPage = async (
|
|||||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||||
})`)) as { width: number; height: number }
|
})`)) as { width: number; height: number }
|
||||||
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
|
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
|
||||||
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height)))
|
||||||
reportWindow.setContentSize(width, height)
|
const [currentWidth, currentHeight] = reportWindow.getContentSize()
|
||||||
|
if (currentWidth !== width || currentHeight !== height) {
|
||||||
|
const resized = new Promise<void>((resolve) => reportWindow.once('resize', resolve))
|
||||||
|
reportWindow.setContentSize(width, height)
|
||||||
|
await resized
|
||||||
|
}
|
||||||
|
await reportWindow.webContents.executeJavaScript(
|
||||||
|
'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'
|
||||||
|
)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||||
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
|
const image = await reportWindow.webContents.capturePage(
|
||||||
|
{ x: 0, y: 0, width, height },
|
||||||
|
{ stayHidden: true }
|
||||||
|
)
|
||||||
const png = image.toPNG()
|
const png = image.toPNG()
|
||||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||||
await fs.writeFile(pngPath, png)
|
await fs.writeFile(pngPath, png)
|
||||||
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
||||||
return `data:image/png;base64,${png.toString('base64')}`
|
return `data:image/png;base64,${png.toString('base64')}`
|
||||||
} finally {
|
} finally {
|
||||||
|
reportWindow.webContents.session.webRequest.onBeforeRequest(null)
|
||||||
reportWindow.destroy()
|
reportWindow.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,10 +1115,14 @@ export const exportGroupReport = async (
|
|||||||
// === enrich 在 render 之前:从群成员快照反推真头像 ===
|
// === enrich 在 render 之前:从群成员快照反推真头像 ===
|
||||||
await enrichAvatarsFromGroup(request.metadata)
|
await enrichAvatarsFromGroup(request.metadata)
|
||||||
|
|
||||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
const outputDir =
|
||||||
|
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||||
|
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||||
await fs.ensureDir(outputDir)
|
await fs.ensureDir(outputDir)
|
||||||
const templateLabel =
|
const resolvedTemplate = await resolveTemplate(request)
|
||||||
request.templateId === 'v1'
|
const templateLabel = request.templateRef
|
||||||
|
? resolvedTemplate.definition.fileLabel
|
||||||
|
: request.templateId === 'v1'
|
||||||
? '经典版'
|
? '经典版'
|
||||||
: request.templateId === 'v2'
|
: request.templateId === 'v2'
|
||||||
? '丰富版'
|
? '丰富版'
|
||||||
@@ -846,7 +1135,10 @@ export const exportGroupReport = async (
|
|||||||
await fs.writeFile(htmlPath, html, 'utf8')
|
await fs.writeFile(htmlPath, html, 'utf8')
|
||||||
const htmlEndedAt = new Date()
|
const htmlEndedAt = new Date()
|
||||||
const pngStartedAt = new Date()
|
const pngStartedAt = new Date()
|
||||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||||
|
...resolvedTemplate.definition,
|
||||||
|
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||||
|
})
|
||||||
const pngEndedAt = new Date()
|
const pngEndedAt = new Date()
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -877,9 +1169,12 @@ export const exportGroupReportSnapshot = async (
|
|||||||
request: GroupReportRenderSnapshotExportRequest
|
request: GroupReportRenderSnapshotExportRequest
|
||||||
): Promise<GroupReportExportResult> => {
|
): Promise<GroupReportExportResult> => {
|
||||||
try {
|
try {
|
||||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
const outputDir =
|
||||||
|
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||||
|
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||||
await fs.ensureDir(outputDir)
|
await fs.ensureDir(outputDir)
|
||||||
const templateLabel = getReportTemplate(request.templateId).fileLabel
|
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||||
|
const templateLabel = resolvedTemplate.definition.fileLabel
|
||||||
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
|
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
|
||||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||||
@@ -888,7 +1183,10 @@ export const exportGroupReportSnapshot = async (
|
|||||||
await fs.writeFile(htmlPath, html, 'utf8')
|
await fs.writeFile(htmlPath, html, 'utf8')
|
||||||
const htmlEndedAt = new Date()
|
const htmlEndedAt = new Date()
|
||||||
const pngStartedAt = new Date()
|
const pngStartedAt = new Date()
|
||||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||||
|
...resolvedTemplate.definition,
|
||||||
|
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||||
|
})
|
||||||
const pngEndedAt = new Date()
|
const pngEndedAt = new Date()
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
+225
-1
@@ -11,10 +11,22 @@ import {
|
|||||||
import { exportGroupReport } from './group-report-service'
|
import { exportGroupReport } from './group-report-service'
|
||||||
import { GroupReportExportRequest } from '../shared/group-report'
|
import { GroupReportExportRequest } from '../shared/group-report'
|
||||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||||
|
import { scheduledReportService } from './services/scheduled-report-service'
|
||||||
|
import { personalWechatCapabilityService } from './services/personal-wechat-capability-service'
|
||||||
|
import {
|
||||||
|
ScheduledReportApiError,
|
||||||
|
ScheduledReportApiService,
|
||||||
|
type ScheduledReportApiDependencies
|
||||||
|
} from './services/scheduled-report-api-service'
|
||||||
|
import type {
|
||||||
|
ScheduledReportApiCreateRequest,
|
||||||
|
ScheduledReportApiUpdateRequest
|
||||||
|
} from '../shared/scheduled-report-api'
|
||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||||
import { apiTokenStore } from './api-token-store'
|
import { apiTokenStore } from './api-token-store'
|
||||||
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
|
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
|
||||||
|
import { LocalQueryApiService } from './services/local-query-api-service'
|
||||||
|
|
||||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||||
export const DEFAULT_HTTP_PORT = 6131
|
export const DEFAULT_HTTP_PORT = 6131
|
||||||
@@ -35,6 +47,17 @@ interface RouteContext {
|
|||||||
export interface HttpServerOptions {
|
export interface HttpServerOptions {
|
||||||
tokenProvider?: () => string | null
|
tokenProvider?: () => string | null
|
||||||
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
||||||
|
scheduledReportService?: ScheduledReportApiDependencies['service']
|
||||||
|
scheduledReportCapabilityProvider?: ScheduledReportApiDependencies['getCapability']
|
||||||
|
scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts']
|
||||||
|
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
|
||||||
|
scheduledReportPlatform?: NodeJS.Platform
|
||||||
|
queryApiService?: LocalQueryApiService
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuredQueryApiService: LocalQueryApiService | undefined
|
||||||
|
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
|
||||||
|
configuredQueryApiService = service
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||||
@@ -67,7 +90,7 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
|||||||
if (!isAllowedCorsOrigin(origin)) return false
|
if (!isAllowedCorsOrigin(origin)) return false
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
res.setHeader('Vary', 'Origin')
|
res.setHeader('Vary', 'Origin')
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -318,6 +341,11 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
if (!request?.report || !request?.metadata) {
|
if (!request?.report || !request?.metadata) {
|
||||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||||
}
|
}
|
||||||
|
if (request.templateRef !== undefined) {
|
||||||
|
return sendError(res, 400, 'HTTP API 暂不支持外部日报模板,请使用内置 templateId', {
|
||||||
|
code: 'external_template_unsupported'
|
||||||
|
})
|
||||||
|
}
|
||||||
const result = await exportGroupReport(request)
|
const result = await exportGroupReport(request)
|
||||||
sendJson(res, result.success ? 200 : 500, result)
|
sendJson(res, result.success ? 200 : 500, result)
|
||||||
},
|
},
|
||||||
@@ -367,7 +395,199 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
|
||||||
|
const WECHAT_SEND_CAPABILITY_ROUTE = '/api/v1/wechat-personal/send-capability'
|
||||||
|
|
||||||
|
function parseJsonBody(body: unknown): unknown {
|
||||||
|
if (typeof body !== 'string' || !body.trim()) {
|
||||||
|
throw new ScheduledReportApiError(400, 'invalid_request', '请求体不能为空')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(body)
|
||||||
|
} catch {
|
||||||
|
throw new ScheduledReportApiError(400, 'invalid_request', '请求体 JSON 解析失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendScheduledError(res: ServerResponse, error: unknown): void {
|
||||||
|
if (error instanceof ScheduledReportApiError) {
|
||||||
|
sendJson(res, error.status, {
|
||||||
|
error: error.code,
|
||||||
|
message: error.message,
|
||||||
|
...(error.details !== undefined ? { details: error.details } : {})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
safeError('[HttpServer] scheduled report request failed:', error)
|
||||||
|
sendJson(res, 500, {
|
||||||
|
error: 'internal_error',
|
||||||
|
message: '定时日报 API 执行失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScheduledReportApi(options: HttpServerOptions): ScheduledReportApiService {
|
||||||
|
return new ScheduledReportApiService({
|
||||||
|
service: options.scheduledReportService || scheduledReportService,
|
||||||
|
getCapability:
|
||||||
|
options.scheduledReportCapabilityProvider ||
|
||||||
|
(() => personalWechatCapabilityService.getPersonalWechatSendCapability()),
|
||||||
|
listContacts: options.scheduledReportContactsProvider || listContacts,
|
||||||
|
isDatabaseReady: options.scheduledReportDatabaseReadyProvider || isReady,
|
||||||
|
platform: options.scheduledReportPlatform
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScheduledReportRoute(
|
||||||
|
pathname: string,
|
||||||
|
api: ScheduledReportApiService
|
||||||
|
): RouteHandler | undefined {
|
||||||
|
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
|
||||||
|
return async ({ req, res }) => {
|
||||||
|
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||||
|
try {
|
||||||
|
sendJson(res, 200, { capability: await api.getCapability() })
|
||||||
|
} catch (error) {
|
||||||
|
sendScheduledError(res, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === SCHEDULED_REPORTS_ROUTE) {
|
||||||
|
return async ({ req, res, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const tasks = await api.list()
|
||||||
|
sendJson(res, 200, { count: tasks.length, tasks })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
sendError(res, 405, '需要 GET 或 POST 请求')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const task = await api.create(parseJsonBody(body) as ScheduledReportApiCreateRequest)
|
||||||
|
sendJson(res, 201, { created: true, task })
|
||||||
|
} catch (error) {
|
||||||
|
sendScheduledError(res, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
|
||||||
|
if (pathname.startsWith(retryPrefix)) {
|
||||||
|
const segments = pathname.slice(retryPrefix.length).split('/').filter(Boolean)
|
||||||
|
if (segments.length !== 2 || segments[1] !== 'retry-send') return undefined
|
||||||
|
let executionId: string
|
||||||
|
try {
|
||||||
|
executionId = decodeURIComponent(segments[0])
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return async ({ req, res }) => {
|
||||||
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||||
|
try {
|
||||||
|
const execution = await api.retrySend(executionId)
|
||||||
|
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||||
|
} catch (error) {
|
||||||
|
sendScheduledError(res, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
||||||
|
if (!pathname.startsWith(prefix)) return undefined
|
||||||
|
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
||||||
|
if (!segments.length || segments.length > 2) return undefined
|
||||||
|
let taskId: string
|
||||||
|
try {
|
||||||
|
taskId = decodeURIComponent(segments[0])
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const action = segments[1]
|
||||||
|
|
||||||
|
return async ({ req, res, body }) => {
|
||||||
|
try {
|
||||||
|
if (!action && req.method === 'GET') {
|
||||||
|
sendJson(res, 200, { task: await api.get(taskId) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!action && req.method === 'PATCH') {
|
||||||
|
const task = await api.update(
|
||||||
|
taskId,
|
||||||
|
parseJsonBody(body) as ScheduledReportApiUpdateRequest
|
||||||
|
)
|
||||||
|
sendJson(res, 200, { updated: true, task })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!action && req.method === 'DELETE') {
|
||||||
|
sendJson(res, 200, { deleted: true, ...(await api.delete(taskId)) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'enable' && req.method === 'POST') {
|
||||||
|
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, true) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'disable' && req.method === 'POST') {
|
||||||
|
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, false) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'run' && req.method === 'POST') {
|
||||||
|
const execution = await api.run(taskId)
|
||||||
|
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'executions' && req.method === 'GET') {
|
||||||
|
const executions = await api.executions(taskId)
|
||||||
|
sendJson(res, 200, { count: executions.length, executions })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendError(res, 405, '请求方法或定时日报操作不受支持')
|
||||||
|
} catch (error) {
|
||||||
|
sendScheduledError(res, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
||||||
|
const QUERY_ROUTE_PREFIX = '/api/v1/query/'
|
||||||
|
|
||||||
|
function queryStatusCode(status: string): number {
|
||||||
|
if (status === 'completed') return 200
|
||||||
|
if (status === 'contact_not_found') return 404
|
||||||
|
if (status === 'ambiguous_contact') return 409
|
||||||
|
if (status === 'knowledge_unavailable') return 503
|
||||||
|
if (status === 'retrieval_incomplete') return 206
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
|
||||||
|
return async ({ req, res, body }) => {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
||||||
|
if (pathname === '/api/v1/query/capabilities') {
|
||||||
|
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||||
|
return sendJson(res, 200, api.capabilities())
|
||||||
|
}
|
||||||
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
|
||||||
|
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
|
||||||
|
try {
|
||||||
|
const result = pathname === '/api/v1/query/messages'
|
||||||
|
? await api.messages(payload)
|
||||||
|
: pathname === '/api/v1/query/search'
|
||||||
|
? await api.search(payload)
|
||||||
|
: pathname === '/api/v1/query/message-context'
|
||||||
|
? await api.context(payload)
|
||||||
|
: pathname === '/api/v1/query/conversation-overview'
|
||||||
|
? await api.overview(payload)
|
||||||
|
: undefined
|
||||||
|
if (!result) return sendError(res, 404, `端点不存在: ${pathname}`)
|
||||||
|
return sendJson(res, queryStatusCode(result.status), result)
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createMediaRoute(
|
function createMediaRoute(
|
||||||
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
||||||
@@ -424,6 +644,8 @@ export function startHttpServer(
|
|||||||
): Promise<HttpServerHandle> {
|
): Promise<HttpServerHandle> {
|
||||||
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
||||||
const mediaProvider = options.mediaProvider || readImageMedia
|
const mediaProvider = options.mediaProvider || readImageMedia
|
||||||
|
const scheduledReportApi = createScheduledReportApi(options)
|
||||||
|
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server: Server = http.createServer(async (req, res) => {
|
const server: Server = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -437,6 +659,8 @@ export function startHttpServer(
|
|||||||
}
|
}
|
||||||
const handler =
|
const handler =
|
||||||
routes[url.pathname] ||
|
routes[url.pathname] ||
|
||||||
|
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
|
||||||
|
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
|
||||||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
||||||
? createMediaRoute(mediaProvider)
|
? createMediaRoute(mediaProvider)
|
||||||
: undefined)
|
: undefined)
|
||||||
|
|||||||
@@ -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()) {
|
for (const candidate of getFfmpegCandidates()) {
|
||||||
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
|
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
|
||||||
if (pathLike) {
|
if (pathLike) {
|
||||||
|
|||||||
+699
-68
File diff suppressed because it is too large
Load Diff
+345
-69
@@ -1,11 +1,12 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { execFile } from 'child_process'
|
import { execFile, spawn } from 'child_process'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { isValidDatabaseKey } from './database-key-store'
|
import { isValidDatabaseKey } from './database-key-store'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { findResource, getResourceCandidates } from './resource-paths'
|
import { findResource, getResourceCandidates } from './resource-paths'
|
||||||
|
import { detectWechatVersion } from './services/connection-diagnostics'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -24,12 +25,288 @@ export interface ImageKeyResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type XkeyHelperMode = 'legacy' | 'wechat-4.1.13'
|
||||||
|
|
||||||
|
export function resolveXkeyHelperMode(wechatVersion: string): XkeyHelperMode {
|
||||||
|
return /^4\.1\.13(?:\.|$)/.test(wechatVersion.trim()) ? 'wechat-4.1.13' : 'legacy'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildXkeyHelperArguments(pid: number, timeoutMs: number): string[] {
|
||||||
|
return [String(pid), String(timeoutMs), '--profile', 'wechat-4.1.13', '--account']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAppleSiliconXkeyInvocation(
|
||||||
|
wechatVersion: string,
|
||||||
|
pid: number,
|
||||||
|
timeoutMs: number
|
||||||
|
): {
|
||||||
|
mode: XkeyHelperMode
|
||||||
|
resourceName: string
|
||||||
|
args: string[]
|
||||||
|
waitMs: number
|
||||||
|
timeoutSeconds: number
|
||||||
|
execTimeoutMs: number
|
||||||
|
} {
|
||||||
|
const mode = resolveXkeyHelperMode(wechatVersion)
|
||||||
|
const isWechat413 = mode === 'wechat-4.1.13'
|
||||||
|
const waitMs = Math.max(isWechat413 ? 120_000 : 30_000, timeoutMs)
|
||||||
|
const timeoutSeconds = Math.ceil(waitMs / 1000) + (isWechat413 ? 10 : 30)
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
resourceName: isWechat413 ? 'xkey_helper_4_1_13' : 'xkey_helper',
|
||||||
|
args: isWechat413 ? buildXkeyHelperArguments(pid, waitMs) : [String(pid), String(waitMs)],
|
||||||
|
waitMs,
|
||||||
|
timeoutSeconds,
|
||||||
|
execTimeoutMs: isWechat413 ? timeoutSeconds * 1000 + 5_000 : waitMs + 20_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapXkeyHelperFailure(
|
||||||
|
rawError: string,
|
||||||
|
fallbackCode = 'HELPER_RESULT_INVALID'
|
||||||
|
): DatabaseKeyResult {
|
||||||
|
const normalizedError = rawError.trim().toLowerCase()
|
||||||
|
const parsedError = rawError.match(/(?:^|[\s"])(?:\\?"?)ERROR:([^:\s"}]+):?([^"}\r\n]*)/i)
|
||||||
|
const code = parsedError?.[1]?.toUpperCase()
|
||||||
|
const detail = parsedError?.[2]?.trim() || ''
|
||||||
|
|
||||||
|
if (
|
||||||
|
code === 'CAPTURE_TIMEOUT' ||
|
||||||
|
normalizedError.includes('timeout waiting for breakpoint hit') ||
|
||||||
|
normalizedError.includes('timeout waiting for sink hit') ||
|
||||||
|
normalizedError.includes('no_breakpoint_hit')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: 'CAPTURE_TIMEOUT',
|
||||||
|
error:
|
||||||
|
'已完成管理员授权,但监听期间微信没有触发账号密钥派生。请先停留在微信登录界面,在 TraceMemo 点击“自动获取密钥”,授权后点击微信“登录”;已有登录凭据时通常不需要扫码。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code,
|
||||||
|
error:
|
||||||
|
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||||
|
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||||
|
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (code === 'SCAN_FAILED') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code,
|
||||||
|
error: '内存扫描失败:当前微信版本或运行状态暂未适配。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedError.includes('permission denied') || code === 'PERMISSION_DENIED') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: code || 'PERMISSION_DENIED',
|
||||||
|
error: '管理员授权不足,无法读取微信进程内存。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: code || fallbackCode,
|
||||||
|
error: code
|
||||||
|
? `密钥工具执行未完成(${code}),请确认微信仍在运行后重试。`
|
||||||
|
: '密钥工具未返回有效密钥,请确认微信仍在运行后重试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
|
||||||
|
const payloads: Record<string, unknown>[] = []
|
||||||
|
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
||||||
|
try {
|
||||||
|
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
||||||
|
} catch {
|
||||||
|
// Ignore helper progress that is not JSON.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
||||||
|
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
||||||
|
if (isValidDatabaseKey(rawKey)) return { success: true, key: rawKey }
|
||||||
|
|
||||||
|
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
||||||
|
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
||||||
|
return mapXkeyHelperFailure(rawError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 {
|
export class KeyServiceMac {
|
||||||
private getHelperPath(): string {
|
private getMacKeyRuntimeDir(): string {
|
||||||
const helperPath = findResource('xkey_helper')
|
return path.join(app.getPath('userData'), 'key-runtime')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMacKeyTool(
|
||||||
|
args: string[],
|
||||||
|
timeoutMs: number,
|
||||||
|
onStatus?: (message: string) => void
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const helperPath = findResource('macos-key-tool/intel_mac_key_helper')
|
||||||
|
if (!helperPath) {
|
||||||
|
return {
|
||||||
|
type: 'result',
|
||||||
|
success: false,
|
||||||
|
code: 'KEY_TOOL_UNAVAILABLE',
|
||||||
|
error: '缺少 Intel Mac 工具'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise<Record<string, unknown>>((resolve) => {
|
||||||
|
const child = spawn(helperPath, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
let settled = false
|
||||||
|
let pending = ''
|
||||||
|
let lastError = ''
|
||||||
|
let finalPayload: Record<string, unknown> | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const finish = (payload: Record<string, unknown>): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
resolve(payload)
|
||||||
|
}
|
||||||
|
const consume = (chunk: Buffer): void => {
|
||||||
|
pending += chunk.toString()
|
||||||
|
const lines = pending.split(/\r?\n/)
|
||||||
|
pending = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(line) as Record<string, unknown>
|
||||||
|
if (payload.type === 'progress' && typeof payload.message === 'string') {
|
||||||
|
onStatus?.(payload.message)
|
||||||
|
}
|
||||||
|
if (payload.type === 'result' || payload.type === 'status') finalPayload = payload
|
||||||
|
} catch {
|
||||||
|
// The helper contract is JSONL; ignore interpreter diagnostics.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.stdout.on('data', consume)
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
lastError = `${lastError}\n${chunk.toString()}`.trim().slice(-1000)
|
||||||
|
})
|
||||||
|
child.on('error', (error) =>
|
||||||
|
finish({
|
||||||
|
type: 'result',
|
||||||
|
success: false,
|
||||||
|
code: 'KEY_TOOL_FAILED',
|
||||||
|
error: `无法启动 Intel Mac 密钥工具:${error.message}`
|
||||||
|
})
|
||||||
|
)
|
||||||
|
child.on('close', () => {
|
||||||
|
if (pending) consume(Buffer.from('\n'))
|
||||||
|
finish(
|
||||||
|
finalPayload || {
|
||||||
|
type: 'result',
|
||||||
|
success: false,
|
||||||
|
code: 'KEY_TOOL_FAILED',
|
||||||
|
error: lastError || 'Intel Mac 密钥工具未返回结果'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
// The child may already have exited.
|
||||||
|
}
|
||||||
|
finish({
|
||||||
|
type: 'result',
|
||||||
|
success: false,
|
||||||
|
code: 'KEY_TOOL_TIMEOUT',
|
||||||
|
error: 'Intel Mac 密钥获取超时,请让微信回到未登录界面后重试'
|
||||||
|
})
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIntelEnvironmentStatus(): Promise<{
|
||||||
|
sipDisabled: boolean
|
||||||
|
pythonAvailable: boolean
|
||||||
|
fridaAvailable: boolean
|
||||||
|
wechatAdhocSigned: boolean
|
||||||
|
}> {
|
||||||
|
const payload = await this.runMacKeyTool(
|
||||||
|
['status', '--runtime-dir', this.getMacKeyRuntimeDir()],
|
||||||
|
15_000
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
sipDisabled: payload.sipDisabled === true,
|
||||||
|
pythonAvailable: payload.pythonAvailable === true,
|
||||||
|
fridaAvailable: payload.fridaAvailable === true,
|
||||||
|
wechatAdhocSigned: payload.wechatAdhocSigned === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installIntelKeyRuntime(
|
||||||
|
onStatus?: (message: string) => void
|
||||||
|
): Promise<{ success: boolean; error?: string; code?: string }> {
|
||||||
|
if (process.platform !== 'darwin' || process.arch !== 'x64') {
|
||||||
|
return { success: false, code: 'UNSUPPORTED_PLATFORM', error: '仅 Intel Mac 需要安装此运行环境' }
|
||||||
|
}
|
||||||
|
onStatus?.('正在准备连接环境,请保持网络连接…')
|
||||||
|
const payload = await this.runMacKeyTool(
|
||||||
|
['install', this.getMacKeyRuntimeDir()],
|
||||||
|
5 * 60_000,
|
||||||
|
onStatus
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: payload.success === true,
|
||||||
|
code: typeof payload.code === 'string' ? payload.code : undefined,
|
||||||
|
error: typeof payload.error === 'string' ? payload.error : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async captureIntelDbKey(
|
||||||
|
accountRoot: string | undefined,
|
||||||
|
timeoutMs: number,
|
||||||
|
onStatus?: (message: string) => void
|
||||||
|
): Promise<DatabaseKeyResult> {
|
||||||
|
const selectedRoot = String(accountRoot || '').trim()
|
||||||
|
if (!selectedRoot) return { success: false, code: 'ACCOUNT_REQUIRED', error: '请先选择微信账号' }
|
||||||
|
const waitMs = Math.max(30_000, timeoutMs)
|
||||||
|
const payload = await this.runMacKeyTool(
|
||||||
|
[
|
||||||
|
'capture',
|
||||||
|
'--account-root',
|
||||||
|
selectedRoot,
|
||||||
|
'--runtime-dir',
|
||||||
|
this.getMacKeyRuntimeDir(),
|
||||||
|
'--diagnostic-log',
|
||||||
|
path.join(app.getPath('logs'), 'mac-key-diagnostic.log'),
|
||||||
|
'--timeout-ms',
|
||||||
|
String(waitMs)
|
||||||
|
],
|
||||||
|
waitMs + 15_000,
|
||||||
|
onStatus
|
||||||
|
)
|
||||||
|
const key = typeof payload.key === 'string' ? payload.key.trim().toLowerCase() : ''
|
||||||
|
if (payload.success === true && isValidDatabaseKey(key)) return { success: true, key }
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: typeof payload.code === 'string' ? payload.code : 'KEY_TOOL_FAILED',
|
||||||
|
error: typeof payload.error === 'string' ? payload.error : '未捕获到微信数据库密钥'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHelperPath(resourceName = 'xkey_helper'): string {
|
||||||
|
const helperPath = findResource(resourceName)
|
||||||
if (!helperPath) {
|
if (!helperPath) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})`
|
`找不到 ${resourceName}(已检查:${getResourceCandidates(resourceName).join(';')})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return helperPath
|
return helperPath
|
||||||
@@ -38,7 +315,7 @@ export class KeyServiceMac {
|
|||||||
private async isSipEnabled(): Promise<boolean> {
|
private async isSipEnabled(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||||
return stdout.toLowerCase().includes('enabled')
|
return parseSipEnabled(stdout)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -61,77 +338,44 @@ export class KeyServiceMac {
|
|||||||
// Try the next process lookup strategy.
|
// Try the next process lookup strategy.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error('未找到微信主进程,请先启动并登录微信')
|
throw new Error('未找到微信主进程,请先启动微信并停留在登录界面')
|
||||||
}
|
|
||||||
|
|
||||||
private parseHelperOutput(output: string): DatabaseKeyResult {
|
|
||||||
const payloads: Record<string, unknown>[] = []
|
|
||||||
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
|
||||||
try {
|
|
||||||
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
|
||||||
} catch {
|
|
||||||
// Ignore helper progress that is not JSON.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
|
||||||
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
|
||||||
if (!isValidDatabaseKey(rawKey)) {
|
|
||||||
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
|
||||||
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
|
||||||
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
|
|
||||||
const code = parsedError?.[1]?.toUpperCase()
|
|
||||||
const detail = parsedError?.[2]?.trim() || ''
|
|
||||||
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code,
|
|
||||||
error:
|
|
||||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
|
||||||
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
|
||||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (code === 'SCAN_FAILED') {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code,
|
|
||||||
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code,
|
|
||||||
error: rawError || '密钥工具未返回有效的 64 位密钥'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, key: rawKey }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async autoGetDbKey(
|
async autoGetDbKey(
|
||||||
onStatus?: (message: string) => void,
|
onStatus?: (message: string) => void,
|
||||||
timeoutMs = 60_000
|
timeoutMs = 60_000,
|
||||||
|
accountRoot?: string
|
||||||
): Promise<DatabaseKeyResult> {
|
): Promise<DatabaseKeyResult> {
|
||||||
|
if (onStatus) {
|
||||||
|
const emitStatus = onStatus
|
||||||
|
onStatus = (message: string): void =>
|
||||||
|
emitStatus(message.replace(/Frida/gi, '连接组件'))
|
||||||
|
}
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
|
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
|
||||||
}
|
}
|
||||||
if (await this.isSipEnabled()) {
|
if (await this.isSipEnabled()) {
|
||||||
return {
|
return { success: false, code: 'SIP_ENABLED', error: SIP_ENABLED_ERROR }
|
||||||
success: false,
|
|
||||||
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (process.arch === 'x64') {
|
||||||
|
onStatus?.('Intel Mac 将通过 Frida 获取微信主密钥,请让微信停留在未登录界面')
|
||||||
|
const result = await this.captureIntelDbKey(accountRoot, timeoutMs, onStatus)
|
||||||
|
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const wechatVersion = await detectWechatVersion()
|
||||||
onStatus?.('正在查找微信进程...')
|
onStatus?.('正在查找微信进程...')
|
||||||
const pid = await this.getWeChatPid()
|
const pid = await this.getWeChatPid()
|
||||||
const helperPath = this.getHelperPath()
|
const invocation = buildAppleSiliconXkeyInvocation(wechatVersion, pid, timeoutMs)
|
||||||
const waitMs = Math.max(30_000, timeoutMs)
|
const isWechat413 = invocation.mode === 'wechat-4.1.13'
|
||||||
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
|
const helperPath = this.getHelperPath(invocation.resourceName)
|
||||||
onStatus?.('正在请求管理员授权...')
|
onStatus?.('正在请求管理员授权...')
|
||||||
const scriptLines = [
|
const scriptLines = [
|
||||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
`set cmd to quoted form of helperPath & " ${invocation.args.join(' ')}"`,
|
||||||
`set timeoutSec to ${timeoutSeconds}`,
|
`set timeoutSec to ${invocation.timeoutSeconds}`,
|
||||||
'try',
|
'try',
|
||||||
'with timeout of timeoutSec seconds',
|
'with timeout of timeoutSec seconds',
|
||||||
'set outText to do shell script cmd with administrator privileges',
|
'set outText to do shell script cmd with administrator privileges',
|
||||||
@@ -141,25 +385,57 @@ export class KeyServiceMac {
|
|||||||
'return "ERR::" & errNum & "::" & errMsg',
|
'return "ERR::" & errNum & "::" & errMsg',
|
||||||
'end try'
|
'end try'
|
||||||
]
|
]
|
||||||
onStatus?.('授权后 需要在微信登录界面 点击登录微信')
|
onStatus?.(
|
||||||
|
isWechat413
|
||||||
|
? '授权后请在微信登录界面点击“登录”,已有登录凭据时通常不需要扫码'
|
||||||
|
: '授权后 需要在微信登录界面 点击登录微信'
|
||||||
|
)
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'/usr/bin/osascript',
|
'/usr/bin/osascript',
|
||||||
scriptLines.flatMap((line) => ['-e', line]),
|
scriptLines.flatMap((line) => ['-e', line]),
|
||||||
{ timeout: waitMs + 20_000 }
|
{ timeout: invocation.execTimeoutMs }
|
||||||
)
|
)
|
||||||
const output = String(stdout).trim()
|
const output = String(stdout).trim()
|
||||||
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
|
if (output.startsWith('ERR::-128')) {
|
||||||
if (output.startsWith('ERR::')) {
|
return { success: false, error: '已取消管理员授权' }
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
if (output.startsWith('ERR::')) {
|
||||||
|
const [, errorNumber = 'UNKNOWN', ...errorParts] = output.split('::')
|
||||||
|
const result = mapXkeyHelperFailure(errorParts.join('::'), `OSASCRIPT_${errorNumber}`)
|
||||||
|
onStatus?.('密钥获取失败')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const result = parseXkeyHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
||||||
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
const processError = error as NodeJS.ErrnoException & {
|
||||||
|
killed?: boolean
|
||||||
|
signal?: NodeJS.Signals | null
|
||||||
|
}
|
||||||
|
if (processError.message?.includes('未找到微信主进程')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: 'WECHAT_NOT_RUNNING',
|
||||||
|
error: '未找到微信主进程,请先启动微信并停留在登录界面。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
processError.killed ||
|
||||||
|
processError.code === 'ETIMEDOUT' ||
|
||||||
|
processError.signal === 'SIGTERM'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: 'AUTH_TIMEOUT',
|
||||||
|
error: '管理员授权等待超时,请点击“自动获取密钥”后及时完成系统授权。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: typeof processError.code === 'string' ? processError.code : 'HELPER_EXEC_FAILED',
|
||||||
|
error: '密钥工具执行失败,请确认微信仍在运行后重试。'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { join, dirname, delimiter } from 'path'
|
import { join, dirname, delimiter } from 'path'
|
||||||
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
import { existsSync, copyFileSync, mkdirSync, readdirSync } from 'fs'
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
|
import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
|
||||||
|
import {
|
||||||
|
deriveV4ImageKeys,
|
||||||
|
extractKvcommCode,
|
||||||
|
normalizeV4AccountId
|
||||||
|
} from '../shared/wechat-image-key-derivation'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -683,7 +688,8 @@ export class KeyService {
|
|||||||
if (loginRequired) {
|
if (loginRequired) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
error:
|
||||||
|
'微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
||||||
logs
|
logs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,8 +702,7 @@ export class KeyService {
|
|||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
wxidParam?: string
|
wxidParam?: string
|
||||||
): Promise<ImageKeyResult> {
|
): Promise<ImageKeyResult> {
|
||||||
void wxidParam
|
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress, wxidParam)
|
||||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||||
@@ -706,7 +711,8 @@ export class KeyService {
|
|||||||
|
|
||||||
async autoGetImageKeyByMemoryScan(
|
async autoGetImageKeyByMemoryScan(
|
||||||
userDir: string,
|
userDir: string,
|
||||||
onProgress?: (message: string) => void
|
onProgress?: (message: string) => void,
|
||||||
|
wxidParam?: string
|
||||||
): Promise<ImageKeyResult> {
|
): Promise<ImageKeyResult> {
|
||||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||||
|
|
||||||
@@ -781,6 +787,18 @@ export class KeyService {
|
|||||||
|
|
||||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
|
const derived = this._deriveImageKeyByLocalMetadata(
|
||||||
|
userDir,
|
||||||
|
wxidParam,
|
||||||
|
ciphertext,
|
||||||
|
xorKey,
|
||||||
|
onProgress
|
||||||
|
)
|
||||||
|
if (derived) {
|
||||||
|
onProgress?.('通过本机账号元数据推导并验证图片密钥成功')
|
||||||
|
return { success: true, xorKey: derived.xorKey, aesKey: derived.aesKey, verified: true }
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 找微信 PID
|
// 2. 找微信 PID
|
||||||
const pid = await this.findWeChatPid()
|
const pid = await this.findWeChatPid()
|
||||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||||
@@ -811,6 +829,73 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _deriveImageKeyByLocalMetadata(
|
||||||
|
userDir: string,
|
||||||
|
wxidParam: string | undefined,
|
||||||
|
ciphertext: Buffer,
|
||||||
|
expectedXorKey: number,
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
): { xorKey: number; aesKey: string } | null {
|
||||||
|
const codes = new Set<number>()
|
||||||
|
for (const directory of this._getKvcommCandidates(userDir)) {
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile()) continue
|
||||||
|
const code = extractKvcommCode(entry.name)
|
||||||
|
if (code !== null) codes.add(code)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Candidate paths differ across WeChat releases and installations.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountIds = new Set<string>()
|
||||||
|
for (const candidate of [wxidParam, userDir]) {
|
||||||
|
const normalized = normalizeV4AccountId(candidate || '')
|
||||||
|
if (normalized) accountIds.add(normalized)
|
||||||
|
}
|
||||||
|
onProgress?.(`正在校验本机账号元数据候选(code=${codes.size}, account=${accountIds.size})...`)
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
for (const code of codes) {
|
||||||
|
const derived = deriveV4ImageKeys(code, accountId)
|
||||||
|
if (!derived || derived.xorKey !== expectedXorKey) continue
|
||||||
|
if (this._verifyAesKey(Buffer.from(derived.aesKey, 'ascii'), ciphertext)) return derived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getKvcommCandidates(userDir: string): string[] {
|
||||||
|
const candidates: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const add = (candidate: string | undefined) => {
|
||||||
|
if (!candidate) return
|
||||||
|
const normalized = candidate.replace(/[\\/]+$/, '')
|
||||||
|
const identity = normalized.toLowerCase()
|
||||||
|
if (!normalized || seen.has(identity)) return
|
||||||
|
seen.add(identity)
|
||||||
|
candidates.push(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roaming = process.env.APPDATA
|
||||||
|
const local = process.env.LOCALAPPDATA
|
||||||
|
add(roaming && join(roaming, 'Tencent', 'xwechat', 'net', 'kvcomm'))
|
||||||
|
add(roaming && join(roaming, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
|
||||||
|
add(local && join(local, 'Tencent', 'xwechat', 'net', 'kvcomm'))
|
||||||
|
add(local && join(local, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
|
||||||
|
add(local && join(local, 'Tencent', 'WeChat', 'xwechat', 'net', 'kvcomm'))
|
||||||
|
|
||||||
|
let cursor = userDir
|
||||||
|
for (let depth = 0; cursor && depth < 6; depth++) {
|
||||||
|
add(join(cursor, 'net', 'kvcomm'))
|
||||||
|
const parent = dirname(cursor)
|
||||||
|
if (parent === cursor) break
|
||||||
|
cursor = parent
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
private async _findTemplateData(
|
private async _findTemplateData(
|
||||||
userDir: string,
|
userDir: string,
|
||||||
limit: number = 32
|
limit: number = 32
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { monitorEventLoopDelay } from 'perf_hooks'
|
||||||
import * as chat from '../services/chat-service'
|
import * as chat from '../services/chat-service'
|
||||||
import type {
|
import type {
|
||||||
|
KnowledgeImageOcrState,
|
||||||
KnowledgeAttachmentMetadata,
|
KnowledgeAttachmentMetadata,
|
||||||
KnowledgeEvidence,
|
KnowledgeEvidence,
|
||||||
KnowledgeMessageKind,
|
KnowledgeMessageKind,
|
||||||
|
KnowledgePassProgress,
|
||||||
|
KnowledgeRuntimeState,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeSearchRequest,
|
KnowledgeSearchRequest,
|
||||||
KnowledgeSearchIpcRequest,
|
KnowledgeSearchIpcRequest,
|
||||||
@@ -21,6 +25,7 @@ import {
|
|||||||
emptyKnowledgeSearchTimings
|
emptyKnowledgeSearchTimings
|
||||||
} from '../../shared/knowledge'
|
} from '../../shared/knowledge'
|
||||||
import { KnowledgeService } from './knowledge-service'
|
import { KnowledgeService } from './knowledge-service'
|
||||||
|
import { sourceMessageId } from './message-identity'
|
||||||
import {
|
import {
|
||||||
voiceAccountIdentity,
|
voiceAccountIdentity,
|
||||||
voiceMessageIdentity
|
voiceMessageIdentity
|
||||||
@@ -32,6 +37,42 @@ const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
|
|||||||
const MAX_SENDER_ENRICHMENT_SESSIONS = 32
|
const MAX_SENDER_ENRICHMENT_SESSIONS = 32
|
||||||
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000
|
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量读取时向前回看的 overlap(epoch ms)。
|
||||||
|
*
|
||||||
|
* delta 下界 = `checkpoint - DELTA_OVERLAP_MS`,用来吸收 timestamp 边界碰撞。
|
||||||
|
* 它必须远大于 chunker 的 `maxGapMs`,否则跨越下界的 chunk 会缺前半段消息、重建时丢前文。
|
||||||
|
*
|
||||||
|
* ⚠️ 单位:这里是**毫秒**(checkpoint 本身是毫秒)。传给 WCDB 读取层之前必须换成
|
||||||
|
* epoch **秒**(`chat.listMessagesAsync` 的 start/end 是秒)。混用会让 delta 读恒为空,
|
||||||
|
* 且**不会报错**。
|
||||||
|
*/
|
||||||
|
const DELTA_OVERLAP_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* event-loop 滞后采样的分辨率(ms)。
|
||||||
|
*
|
||||||
|
* 用 `monitorEventLoopDelay` 的 histogram 而不是手写 setInterval:后者只能以 interval
|
||||||
|
* 为粒度发现 stall,给不出分位数。
|
||||||
|
*/
|
||||||
|
const LAG_PROBE_RESOLUTION_MS = 10
|
||||||
|
|
||||||
|
/** histogram 的纳秒读数转毫秒。 */
|
||||||
|
function roundMs(nanoseconds: number): number {
|
||||||
|
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) return 0
|
||||||
|
return Math.round(nanoseconds / 1e6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WCDB 读取通道:`interactive` = 交互查询,`background` = 后台索引 pass。 */
|
||||||
|
type WcdbReadLane = 'interactive' | 'background'
|
||||||
|
|
||||||
|
type PendingWcdbRead = {
|
||||||
|
high: boolean
|
||||||
|
run: () => Promise<unknown>
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
type PendingVoiceTranscriptIndex = {
|
type PendingVoiceTranscriptIndex = {
|
||||||
update: VoiceTranscriptUpdate
|
update: VoiceTranscriptUpdate
|
||||||
waiters: Array<{
|
waiters: Array<{
|
||||||
@@ -43,7 +84,13 @@ type PendingVoiceTranscriptIndex = {
|
|||||||
type SenderEnrichmentSession = {
|
type SenderEnrichmentSession = {
|
||||||
lastUsedAt: number
|
lastUsedAt: number
|
||||||
contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>>
|
contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>>
|
||||||
groupSnapshots: Map<string, Awaited<ReturnType<typeof chat.getGroupSnapshotAsync>> | undefined>
|
/**
|
||||||
|
* conversationId → (wxid → displayName)。
|
||||||
|
*
|
||||||
|
* 只缓存"这个群里这些 wxid 解析出来是什么名字",不再缓存整群快照。
|
||||||
|
* 空串表示「查过、确实没有可用名字」,用于避免同一 session 内重复查询。
|
||||||
|
*/
|
||||||
|
groupMemberNames: Map<string, Map<string, string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeOpaqueSenderId(value: string | undefined): boolean {
|
function looksLikeOpaqueSenderId(value: string | undefined): boolean {
|
||||||
@@ -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 {
|
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
|
||||||
if (message.voiceTranscript || message.type === '语音') return 'voice'
|
if (message.voiceTranscript || message.type === '语音') return 'voice'
|
||||||
|
if (message.exportMediaType === 'image' || message.exportMediaType === 'video' || message.exportMediaType === 'sticker') {
|
||||||
|
return message.exportMediaType
|
||||||
|
}
|
||||||
|
if (message.exportMediaType === 'file') return 'file'
|
||||||
|
// 索引路径上 `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') {
|
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
|
||||||
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||||
? 'file'
|
? 'file'
|
||||||
@@ -152,12 +208,16 @@ function toSourceMessage(
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
message: chat.FormattedMessage,
|
message: chat.FormattedMessage,
|
||||||
transcriptOverride?: string
|
transcriptOverride?: string,
|
||||||
|
imageOcr?: { state: KnowledgeImageOcrState; text: string }
|
||||||
): KnowledgeSourceMessage | null {
|
): KnowledgeSourceMessage | null {
|
||||||
if (!message.createTime) return null
|
if (!message.createTime) return null
|
||||||
const extracted = sourceTextAndAttachment(message)
|
const extracted = sourceTextAndAttachment(message)
|
||||||
const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined
|
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 {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -169,7 +229,9 @@ function toSourceMessage(
|
|||||||
kind: sourceKind(message),
|
kind: sourceKind(message),
|
||||||
text: extracted.text,
|
text: extracted.text,
|
||||||
attachment: extracted.attachment,
|
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 statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
|
||||||
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
|
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
|
||||||
private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>()
|
private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>()
|
||||||
private wcdbReadTail: Promise<void> = Promise.resolve()
|
/** WCDB 读取的两条通道:交互(查询)优先于后台(索引 pass)。 */
|
||||||
|
private readonly wcdbPending: PendingWcdbRead[] = []
|
||||||
|
private wcdbReadBusy = false
|
||||||
|
private interactiveQueryDepth = 0
|
||||||
|
private interactiveIdle: Promise<void> = Promise.resolve()
|
||||||
|
private interactiveIdleResolve: (() => void) | null = null
|
||||||
private wcdbQueueMsTotal = 0
|
private wcdbQueueMsTotal = 0
|
||||||
private wcdbExecutionMsTotal = 0
|
private wcdbExecutionMsTotal = 0
|
||||||
|
/**
|
||||||
|
* 图片 OCR 文本解析器(由 main 注入)。
|
||||||
|
*
|
||||||
|
* 与语音同构:派生文本在**主进程**解析后贴到消息上,派生库不进 worker。
|
||||||
|
*/
|
||||||
|
private imageOcrResolver:
|
||||||
|
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
|
||||||
|
| undefined
|
||||||
private voiceTranscriptResolver:
|
private voiceTranscriptResolver:
|
||||||
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
|
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
|
||||||
| undefined
|
| undefined
|
||||||
private voiceIndexTail: Promise<void> = Promise.resolve()
|
private voiceIndexTail: Promise<void> = Promise.resolve()
|
||||||
private voiceIndexFlushScheduled = false
|
private voiceIndexFlushScheduled = false
|
||||||
private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>()
|
private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>()
|
||||||
|
/** 上次由查询触发的追赶同步时间,用于节流(避免每个 Query 都重跑一次索引)。 */
|
||||||
|
private lastCatchUpRequestedAt = 0
|
||||||
|
/** 上一遍完整索引 pass 的实际耗时;用于让"是否值得再追一遍"的门槛自我校准。 */
|
||||||
|
private lastIndexPassMs = 0
|
||||||
|
/** 当前/最近一次 pass 的真实进度(供 UI 区分"追新"与"补历史")。 */
|
||||||
|
private passProgress: KnowledgePassProgress | null = null
|
||||||
|
/** 用户是否已经请求取消当前 pass。取消后主循环在下一个安全点退出。 */
|
||||||
|
private cancelRequested = false
|
||||||
|
private lagHistogram: ReturnType<typeof monitorEventLoopDelay> | null = null
|
||||||
|
private lagMaxMs = 0
|
||||||
|
private lagStats: { p50: number; p95: number; p99: number; max: number } | null = null
|
||||||
|
|
||||||
constructor(userDataPath: string, workerPath: string) {
|
constructor(userDataPath: string, workerPath: string) {
|
||||||
this.service = new KnowledgeService(userDataPath, workerPath)
|
this.service = new KnowledgeService(userDataPath, workerPath)
|
||||||
@@ -218,13 +304,32 @@ export class KnowledgeSearchService {
|
|||||||
if (!accountId) return this.emptyStatus('')
|
if (!accountId) return this.emptyStatus('')
|
||||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||||
if (this.indexing.has(accountId)) return current
|
if (this.indexing.has(accountId)) return current
|
||||||
|
this.cancelRequested = false
|
||||||
|
const startedAt = Date.now()
|
||||||
|
this.startLagProbe()
|
||||||
|
this.passProgress = {
|
||||||
|
// 已经有分片 = 增量追新;完全没有 = 首次全量建立。
|
||||||
|
phase: current.indexedChunkCount > 0 || current.indexedMessageCount > 0 ? 'catchup' : 'full',
|
||||||
|
cancellable: true,
|
||||||
|
startedAt,
|
||||||
|
scannedMessages: 0,
|
||||||
|
indexedMessages: 0,
|
||||||
|
processedConversations: 0,
|
||||||
|
totalConversations: 0,
|
||||||
|
skippedConversations: 0,
|
||||||
|
catchupConversations: 0,
|
||||||
|
backfillConversations: 0,
|
||||||
|
backfillCompletedConversations: 0,
|
||||||
|
mainLoopLagMs: 0
|
||||||
|
}
|
||||||
const started: KnowledgeRuntimeStatus = {
|
const started: KnowledgeRuntimeStatus = {
|
||||||
...current,
|
...current,
|
||||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||||
processedMessages: 0,
|
processedMessages: 0,
|
||||||
totalMessages: current.sourceMessageCount,
|
totalMessages: current.sourceMessageCount,
|
||||||
estimatedRemainingMs: null,
|
estimatedRemainingMs: null,
|
||||||
lastError: undefined
|
lastError: undefined,
|
||||||
|
pass: { ...this.passProgress }
|
||||||
}
|
}
|
||||||
this.publishStatus(started)
|
this.publishStatus(started)
|
||||||
const task = this.indexAccount(accountId)
|
const task = this.indexAccount(accountId)
|
||||||
@@ -239,6 +344,16 @@ export class KnowledgeSearchService {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.indexing.delete(accountId)
|
this.indexing.delete(accountId)
|
||||||
|
this.stopLagProbe()
|
||||||
|
const finishedPass = this.passProgress
|
||||||
|
if (finishedPass) {
|
||||||
|
// 真实结束状态:取消就是取消,绝不留一个假的 indexing。
|
||||||
|
finishedPass.cancellable = false
|
||||||
|
finishedPass.mainLoopLagMs = this.lagMaxMs
|
||||||
|
if (finishedPass.phase !== 'cancelled' && finishedPass.phase !== 'error') {
|
||||||
|
finishedPass.phase = 'idle'
|
||||||
|
}
|
||||||
|
}
|
||||||
void this.refreshStatus(accountId).catch(() => undefined)
|
void this.refreshStatus(accountId).catch(() => undefined)
|
||||||
})
|
})
|
||||||
this.indexing.set(accountId, task)
|
this.indexing.set(accountId, task)
|
||||||
@@ -248,6 +363,26 @@ export class KnowledgeSearchService {
|
|||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消当前正在跑的索引 pass。
|
||||||
|
*
|
||||||
|
* 只中止**索引**(与并发查询是两套独立 Abort scope);当前 batch 安全收尾、
|
||||||
|
* 已提交会话保留不回滚;`run_state` 落到 `cancelled`,不残留 `indexing`;
|
||||||
|
* 下一次 catch-up / 手动同步从 per-conversation checkpoint 继续,不从头全量重扫。
|
||||||
|
*/
|
||||||
|
async cancelCurrentAccountIndex(): Promise<{ cancellable: boolean; cancelled: boolean }> {
|
||||||
|
if (!this.indexing.size) return { cancellable: false, cancelled: false }
|
||||||
|
this.cancelRequested = true
|
||||||
|
if (this.passProgress) this.passProgress.cancellable = false
|
||||||
|
const accountId = this.currentAccountId()
|
||||||
|
if (accountId) {
|
||||||
|
const current = this.statusByAccount.get(accountId)
|
||||||
|
if (current) this.publishStatus({ ...current, pass: this.passSnapshot() })
|
||||||
|
}
|
||||||
|
const cancelled = await this.service.cancelIndex().catch(() => false)
|
||||||
|
return { cancellable: true, cancelled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The voice cache remains owned by the voice pipeline. Knowledge only reads
|
* The voice cache remains owned by the voice pipeline. Knowledge only reads
|
||||||
* a current-account snapshot while constructing a derived local index.
|
* a current-account snapshot while constructing a derived local index.
|
||||||
@@ -258,6 +393,15 @@ export class KnowledgeSearchService {
|
|||||||
this.voiceTranscriptResolver = resolver
|
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
|
* A successful recognition updates its source conversation. Consecutive
|
||||||
* updates for the same conversation are coalesced because a complete
|
* updates for the same conversation are coalesced because a complete
|
||||||
@@ -316,8 +460,10 @@ export class KnowledgeSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
|
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
|
||||||
|
// 源数据最新活跃时间与索引状态无关,先取一次(零额外 WCDB 调用:读的是已缓存的 Session 列表)。
|
||||||
|
const sourceLatestAt = this.sourceLatestAt()
|
||||||
const accountId = this.currentAccountId()
|
const accountId = this.currentAccountId()
|
||||||
if (!accountId) return this.searchFallback(request, 'unavailable')
|
if (!accountId) return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
|
||||||
try {
|
try {
|
||||||
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
|
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -329,24 +475,87 @@ export class KnowledgeSearchService {
|
|||||||
senderIds: request.senderIds,
|
senderIds: request.senderIds,
|
||||||
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
|
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
|
||||||
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
|
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
|
||||||
|
,conversationBoundary: request.conversationBoundary
|
||||||
}
|
}
|
||||||
const result = await this.searchKnowledge(searchRequest)
|
const result = await this.searchKnowledge(searchRequest)
|
||||||
// An existing derived database can answer while its next incremental pass is running.
|
// An existing derived database can answer while its next incremental pass is running.
|
||||||
// Never turn an interactive global search into another full WCDB scan during that pass.
|
// Never turn an interactive global search into another full WCDB scan during that pass.
|
||||||
if (result.state === 'ready' || result.evidence.length) {
|
if (result.state === 'ready' || result.evidence.length) {
|
||||||
return this.toKnowledgeResult(result, request.retrievalSessionId)
|
return { ...(await this.toKnowledgeResult(result, request.retrievalSessionId)), sourceLatestAt }
|
||||||
}
|
}
|
||||||
if (this.indexing.has(accountId)) {
|
if (this.indexing.has(accountId)) {
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
source: 'knowledge',
|
source: 'knowledge',
|
||||||
totalMessages: result.indexedMessageCount
|
totalMessages: result.indexedMessageCount,
|
||||||
|
sourceLatestAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.searchFallback(request, 'unavailable')
|
return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Knowledge] search failed, using legacy fallback:', error)
|
console.warn('[Knowledge] search failed, using legacy fallback:', error)
|
||||||
return this.searchFallback(request, 'error')
|
return { ...(await this.searchFallback(request, 'error')), sourceLatestAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
|
||||||
|
*/
|
||||||
|
sourceLatestAt(): number | null {
|
||||||
|
return chat.getSourceLatestActivityMs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上一遍完整索引 pass 的耗时(ms)。0 表示本进程还没有跑完过一遍。
|
||||||
|
* 调用方用它作为"落后多少才值得再追一遍"的门槛下限,避免在活跃源数据上无限连续索引。
|
||||||
|
*/
|
||||||
|
lastPassDurationMs(): number {
|
||||||
|
return this.lastIndexPassMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前是否有索引任务在跑(用于「复用当前任务」而不是再启动一个)。 */
|
||||||
|
isIndexing(): boolean {
|
||||||
|
// 用 Map 是否为空判断,而不是用当前 accountId 去查:
|
||||||
|
// `currentAccountId()` 会在 `getSelfAccountInfo()` 就绪前后返回不同的值,
|
||||||
|
// 只按单键查会漏掉"其实已经有 pass 在跑",于是又启动一个(两个 pass 抢同一个派生库)。
|
||||||
|
return this.indexing.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动请求一次追赶同步。
|
||||||
|
*
|
||||||
|
* 复用现有增量通道(`startCurrentAccountIndex` 内部是增量的:未变化的会话不会重建分片);
|
||||||
|
* 如果已经在跑就**不**再启动第二个,并把 `triggered` 标为 false,让调用方知道这是复用。
|
||||||
|
*/
|
||||||
|
requestCatchUp(minIntervalMs: number): { triggered: boolean; inProgress: boolean } {
|
||||||
|
const accountId = this.currentAccountId()
|
||||||
|
if (!accountId) return { triggered: false, inProgress: false }
|
||||||
|
if (this.isIndexing()) return { triggered: false, inProgress: true }
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - this.lastCatchUpRequestedAt < minIntervalMs) {
|
||||||
|
return { triggered: false, inProgress: false }
|
||||||
|
}
|
||||||
|
this.lastCatchUpRequestedAt = now
|
||||||
|
this.startCurrentAccountIndex()
|
||||||
|
return { triggered: true, inProgress: this.isIndexing() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等当前索引任务结束,最多等 `budgetMs`。返回是否已经结束。
|
||||||
|
* 全量追赶可能远超查询预算,所以这里必须是**有界**等待,不能无限阻塞交互查询。
|
||||||
|
*/
|
||||||
|
async waitForIndexingComplete(budgetMs: number): Promise<boolean> {
|
||||||
|
const task = this.indexing.values().next().value as Promise<void> | undefined
|
||||||
|
if (!task) return true
|
||||||
|
if (budgetMs <= 0) return false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const timeout = new Promise<false>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(false), budgetMs)
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
return await Promise.race([task.then(() => true, () => true), timeout])
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,66 +591,244 @@ export class KnowledgeSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async indexAccount(accountId: string): Promise<void> {
|
private async indexAccount(accountId: string): Promise<void> {
|
||||||
const contacts = await this.listContacts()
|
// 整遍后台索引走 background 通道:交互查询可以插到它前面,不至于被 pass 拖慢。
|
||||||
let processedMessages = 0
|
const contacts = await this.listContacts('background')
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
// 源侧每会话最后活跃时间(零额外 WCDB 调用:直接来自 Session 列表)。
|
||||||
|
const activity = chat.getConversationActivityMs()
|
||||||
|
// 每个会话已经索引到哪(per-conversation checkpoint)。
|
||||||
|
const marks = await this.service
|
||||||
|
.highWaterMarks({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
|
||||||
|
.catch(() => ({}) as Record<string, number>)
|
||||||
|
// 上一遍记录的源侧边界:被跳过的会话已覆盖到它,不能因为"这一遍没读"而回退。
|
||||||
|
const previouslyCoveredLatestAt =
|
||||||
|
this.statusByAccount.get(accountId)?.indexLatestAt ?? 0
|
||||||
|
|
||||||
|
let scannedMessages = 0
|
||||||
|
let indexedMessages = 0
|
||||||
|
let sourceLatestAt = previouslyCoveredLatestAt
|
||||||
|
let skippedConversations = 0
|
||||||
|
let backfillCompletedConversations = 0
|
||||||
|
|
||||||
|
// 「追最新」与「补历史」是两个工作概念,顺序不能反:
|
||||||
|
// catch-up:已建立 checkpoint、源侧出现新消息 → 用户最关心,排在最前;
|
||||||
|
// backfill:从来没有 checkpoint(历史缺口)→ 必须整段读,排在后面慢慢补。
|
||||||
|
// 这个顺序保证今天的新消息不会被历史缺口堵住。
|
||||||
|
const catchUpContacts: typeof contacts = []
|
||||||
|
const backfillContacts: typeof contacts = []
|
||||||
|
for (const contact of contacts) {
|
||||||
|
const mark = marks[contact.md5]
|
||||||
|
if (mark !== undefined && mark > 0) {
|
||||||
|
const previousActivity = activity.get(contact.md5)
|
||||||
|
// 源侧最后活跃时间不晚于"已经索引到的位置"→ 这个会话没有任何新消息。
|
||||||
|
// 直接跳过:**不读 WCDB、不传 IPC、不写索引**。
|
||||||
|
// 这正是把「一次 pass 处理百万级消息」变成「只处理真正变过的会话」的地方。
|
||||||
|
if (previousActivity !== undefined && previousActivity <= mark) {
|
||||||
|
skippedConversations += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catchUpContacts.push(contact)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 没有 checkpoint → 没有"已经覆盖到哪"的证据,只能整段读(历史 backfill)。
|
||||||
|
//
|
||||||
|
// 这里**不**用「源侧活动(Session 表)里没有它」推断「它不可能有消息」。
|
||||||
|
// 那确实能省掉读一批空联系人的开销,但只要 Session 表在某次读取里不完整
|
||||||
|
// (或用户删过会话),这个推断就会把一个真有消息的会话变成**永久静默不索引**。
|
||||||
|
// 省下来的时间换不来这个风险。
|
||||||
|
backfillContacts.push(contact)
|
||||||
|
}
|
||||||
|
const orderedContacts = [...catchUpContacts, ...backfillContacts]
|
||||||
|
|
||||||
|
if (this.passProgress) {
|
||||||
|
this.passProgress.totalConversations = contacts.length
|
||||||
|
this.passProgress.startedAt = startedAt
|
||||||
|
this.passProgress.skippedConversations = skippedConversations
|
||||||
|
this.passProgress.catchupConversations = catchUpContacts.length
|
||||||
|
this.passProgress.backfillConversations = backfillContacts.length
|
||||||
|
this.passProgress.backfillCompletedConversations = 0
|
||||||
|
this.passProgress.processedConversations = skippedConversations
|
||||||
|
}
|
||||||
this.publishStatus({
|
this.publishStatus({
|
||||||
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
|
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
|
||||||
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
|
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
|
||||||
processedMessages: 0,
|
processedMessages: 0,
|
||||||
totalMessages: null,
|
totalMessages: null,
|
||||||
estimatedRemainingMs: null
|
estimatedRemainingMs: null,
|
||||||
|
pass: this.passSnapshot()
|
||||||
})
|
})
|
||||||
for (const [index, contact] of contacts.entries()) {
|
let cancelled = false
|
||||||
|
for (const [index, contact] of orderedContacts.entries()) {
|
||||||
|
if (this.cancelRequested) {
|
||||||
|
cancelled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 交互查询进行中就让路:背景索引绝不能把用户查询拖慢(见 beginInteractiveQuery)。
|
||||||
|
await this.interactiveIdle
|
||||||
|
const isBackfill = index >= catchUpContacts.length
|
||||||
|
if (this.passProgress) {
|
||||||
|
this.passProgress.phase = isBackfill ? 'backfill' : 'catchup'
|
||||||
|
this.passProgress.processedConversations = skippedConversations + index
|
||||||
|
}
|
||||||
|
const previousActivity = activity.get(contact.md5)
|
||||||
|
const mark = marks[contact.md5]
|
||||||
// WCDB rejects overlapping async pagination. Queue every archive read so
|
// WCDB rejects overlapping async pagination. Queue every archive read so
|
||||||
// background indexing and an interactive fallback search can interleave safely.
|
// background indexing and an interactive fallback search can interleave safely.
|
||||||
const messages = await this.listMessages(contact.md5)
|
//
|
||||||
|
// delta 读取:已建立 checkpoint 时只读 checkpoint 之后的源消息(外加有界 overlap),
|
||||||
|
// 而不是"从历史开头全扫一遍、再判断哪些已经索引过"。
|
||||||
|
//
|
||||||
|
// ⚠️ 单位契约见 `DELTA_OVERLAP_MS`:checkpoint 是**毫秒**,传给 WCDB 读取层
|
||||||
|
// 必须是**秒**,否则 delta 读会被静默过滤成空。
|
||||||
|
const isDelta = mark !== undefined && mark > 0
|
||||||
|
const sinceTime = isDelta
|
||||||
|
? Math.max(0, Math.floor((mark - DELTA_OVERLAP_MS) / 1000))
|
||||||
|
: undefined
|
||||||
|
const messages = await this.listMessages(contact.md5, sinceTime, undefined, 'background')
|
||||||
|
scannedMessages += messages.length
|
||||||
|
// 这一遍扫到的源数据最新时间:用**未过滤**的原始消息计算,
|
||||||
|
// 这样「不可建模」的消息(图片/空正文)不会让 freshness 口径偏旧。
|
||||||
|
let conversationLatest = 0
|
||||||
|
for (const message of messages) {
|
||||||
|
const createTime = (message.createTime || 0) * 1000
|
||||||
|
if (createTime > conversationLatest) conversationLatest = createTime
|
||||||
|
}
|
||||||
|
if (conversationLatest > sourceLatestAt) sourceLatestAt = conversationLatest
|
||||||
const sourceMessages = messages
|
const sourceMessages = messages
|
||||||
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
|
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
|
||||||
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
|
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
|
||||||
await this.service.index(
|
// 记录**源侧**边界(而不是索引里最后一条可建模消息的时间):
|
||||||
|
// 否则"最后一条恰好落在图片上"的会话会永远被判成有新消息,增量永远跳不过它。
|
||||||
|
//
|
||||||
|
// 安全阀:delta 范围读**空**、但源侧声称有新消息 → **不推进** checkpoint。
|
||||||
|
// 宁可下一遍重试,也不让"一次可疑的空读"升级成"谎报已覆盖"(那会静默丢消息)。
|
||||||
|
const suspiciousEmptyDelta =
|
||||||
|
isDelta && messages.length === 0 && (previousActivity ?? 0) > (mark ?? 0)
|
||||||
|
const sourceHighWaterTime = suspiciousEmptyDelta
|
||||||
|
? undefined
|
||||||
|
: Math.max(previousActivity ?? 0, conversationLatest)
|
||||||
|
const result = await this.service.index(
|
||||||
{
|
{
|
||||||
accountId,
|
accountId,
|
||||||
conversations: [
|
conversations: [
|
||||||
{
|
{
|
||||||
conversationId: contact.md5,
|
conversationId: contact.md5,
|
||||||
completeSnapshot: true,
|
// delta 模式下绝不能声明"完整快照":否则 store 会把"不在 delta 里的历史消息"
|
||||||
messages: sourceMessages
|
// 误判为被删除,从而整段重建这个会话,增量就白做了。
|
||||||
|
completeSnapshot: !isDelta,
|
||||||
|
messages: sourceMessages,
|
||||||
|
...(sourceHighWaterTime && sourceHighWaterTime > 0 ? { sourceHighWaterTime } : {})
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||||
|
// 只有「这一遍真的读完了全部会话」(没有任何跳过、没有取消)才写入总量口径,
|
||||||
|
// 否则会把增量 pass 的部分计数冒充成全量。
|
||||||
|
//
|
||||||
|
// 这里数的是真正被建模进索引的源消息,不是"扫到的原始条数";后者(含不可建模的
|
||||||
|
// 图片/空正文)另走 pass 进度里的 scannedMessages。两个数字回答不同问题。
|
||||||
sourceMessageCount:
|
sourceMessageCount:
|
||||||
index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined
|
index === orderedContacts.length - 1 && skippedConversations === 0
|
||||||
|
? indexedMessages + sourceMessages.length
|
||||||
|
: undefined,
|
||||||
|
sourceLatestAt:
|
||||||
|
index === orderedContacts.length - 1 && sourceLatestAt > 0 ? sourceLatestAt : undefined
|
||||||
},
|
},
|
||||||
(progress) => {
|
(progress) => {
|
||||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||||
this.publishStatus({
|
this.publishStatus({
|
||||||
...current,
|
...current,
|
||||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||||
processedMessages: processedMessages + progress.processedMessages,
|
processedMessages: indexedMessages + progress.processedMessages,
|
||||||
totalMessages: null,
|
totalMessages: null,
|
||||||
currentConversationId: progress.conversationId,
|
currentConversationId: progress.conversationId,
|
||||||
estimatedRemainingMs: null
|
estimatedRemainingMs: null,
|
||||||
|
pass: this.passSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
processedMessages += sourceMessages.length
|
if (result.cancelled) {
|
||||||
|
cancelled = true
|
||||||
|
indexedMessages += result.processedMessages
|
||||||
|
break
|
||||||
|
}
|
||||||
|
indexedMessages += sourceMessages.length
|
||||||
|
if (isBackfill) backfillCompletedConversations += 1
|
||||||
|
if (this.passProgress) {
|
||||||
|
this.passProgress.scannedMessages = scannedMessages
|
||||||
|
this.passProgress.indexedMessages = indexedMessages
|
||||||
|
this.passProgress.processedConversations = skippedConversations + index + 1
|
||||||
|
this.passProgress.backfillCompletedConversations = backfillCompletedConversations
|
||||||
|
}
|
||||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||||
this.publishStatus({
|
this.publishStatus({
|
||||||
...current,
|
...current,
|
||||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||||
processedMessages,
|
processedMessages: indexedMessages,
|
||||||
totalMessages: null,
|
totalMessages: null,
|
||||||
currentConversationId: contact.md5,
|
currentConversationId: contact.md5,
|
||||||
estimatedRemainingMs: null
|
estimatedRemainingMs: null,
|
||||||
|
pass: this.passSnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (cancelled && this.passProgress) this.passProgress.phase = 'cancelled'
|
||||||
|
// 一遍 pass 一行汇总(低频、只在结束时输出一次)。这些数字同时喂给 UI 的 pass 进度;
|
||||||
|
// lag 输出分位数而不是单点,因为"最大值看着还行"不能说明没有 stall。
|
||||||
|
console.info(
|
||||||
|
`[Knowledge] pass ${cancelled ? 'cancelled' : 'done'} conversations=${contacts.length} ` +
|
||||||
|
`skipped=${skippedConversations} scanned=${scannedMessages} indexed=${indexedMessages} ` +
|
||||||
|
`catchup=${catchUpContacts.length} backfill=${backfillContacts.length}/${backfillCompletedConversations} ` +
|
||||||
|
`elapsedMs=${Date.now() - startedAt} ` +
|
||||||
|
`lagP50=${this.lagStats?.p50 ?? 0} lagP95=${this.lagStats?.p95 ?? 0} ` +
|
||||||
|
`lagP99=${this.lagStats?.p99 ?? 0} lagMax=${this.lagStats?.max ?? this.lagMaxMs} ` +
|
||||||
|
`activityEntries=${activity.size} checkpointMarks=${Object.keys(marks).length}`
|
||||||
|
)
|
||||||
await this.refreshStatus(accountId, {
|
await this.refreshStatus(accountId, {
|
||||||
processedMessages,
|
processedMessages: indexedMessages,
|
||||||
totalMessages: processedMessages,
|
totalMessages: null,
|
||||||
startedAt
|
startedAt
|
||||||
})
|
})
|
||||||
|
// 取消的一遍不计入"上一遍耗时",否则查询侧的门槛会被一次提前结束的 pass 带偏。
|
||||||
|
if (!cancelled) this.lastIndexPassMs = Date.now() - startedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前 pass 进度的不可变快照(含实时采样到的主线程滞后)。 */
|
||||||
|
private passSnapshot(): KnowledgePassProgress | undefined {
|
||||||
|
if (!this.passProgress) return undefined
|
||||||
|
// 运行期间也要能读到"此刻为止"的最大滞后,而不是等 pass 结束才有数字。
|
||||||
|
const liveMax = this.lagHistogram ? roundMs(this.lagHistogram.max) : this.lagMaxMs
|
||||||
|
return { ...this.passProgress, mainLoopLagMs: Math.max(liveMax, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 pass 开始时启动主线程滞后采样。这是"重活没有压在主线程上"的直接证据。
|
||||||
|
*/
|
||||||
|
private startLagProbe(): void {
|
||||||
|
if (this.lagHistogram) return
|
||||||
|
this.lagMaxMs = 0
|
||||||
|
this.lagStats = null
|
||||||
|
try {
|
||||||
|
const histogram = monitorEventLoopDelay({ resolution: LAG_PROBE_RESOLUTION_MS })
|
||||||
|
histogram.enable()
|
||||||
|
this.lagHistogram = histogram
|
||||||
|
} catch {
|
||||||
|
// 采样失败不应该影响索引本身;退化为"没有 lag 数据"。
|
||||||
|
this.lagHistogram = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopLagProbe(): void {
|
||||||
|
const histogram = this.lagHistogram
|
||||||
|
if (!histogram) return
|
||||||
|
histogram.disable()
|
||||||
|
this.lagStats = {
|
||||||
|
p50: roundMs(histogram.percentile(50)),
|
||||||
|
p95: roundMs(histogram.percentile(95)),
|
||||||
|
p99: roundMs(histogram.percentile(99)),
|
||||||
|
max: roundMs(histogram.max)
|
||||||
|
}
|
||||||
|
this.lagMaxMs = Math.max(this.lagMaxMs, this.lagStats.max)
|
||||||
|
this.lagHistogram = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private async searchFallback(
|
private async searchFallback(
|
||||||
@@ -481,12 +868,14 @@ export class KnowledgeSearchService {
|
|||||||
.filter(({ message, score }) => {
|
.filter(({ message, score }) => {
|
||||||
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
|
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
|
||||||
const termMatches = !terms.length || score > 0
|
const termMatches = !terms.length || score > 0
|
||||||
return senderMatches && termMatches
|
const boundaryMatches = !request.conversationBoundary || message.contentData?.type !== 'system'
|
||||||
|
return senderMatches && termMatches && boundaryMatches
|
||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
right.score - left.score ||
|
right.score - left.score ||
|
||||||
(right.message.createTime || 0) - (left.message.createTime || 0)
|
(request.conversationBoundary === 'first' ? -1 : 1) *
|
||||||
|
((right.message.createTime || 0) - (left.message.createTime || 0))
|
||||||
)
|
)
|
||||||
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
|
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
|
||||||
const result: KnowledgeSearchIpcResult = {
|
const result: KnowledgeSearchIpcResult = {
|
||||||
@@ -495,6 +884,9 @@ export class KnowledgeSearchService {
|
|||||||
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
|
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
|
||||||
indexedMessageCount: 0,
|
indexedMessageCount: 0,
|
||||||
indexedChunkCount: 0,
|
indexedChunkCount: 0,
|
||||||
|
// fallback 是直接扫源数据,不走派生索引,因此没有索引覆盖口径可言。
|
||||||
|
indexLatestAt: null,
|
||||||
|
sourceLatestAt: this.sourceLatestAt(),
|
||||||
totalMessages,
|
totalMessages,
|
||||||
timings: {
|
timings: {
|
||||||
...emptyKnowledgeSearchTimings(),
|
...emptyKnowledgeSearchTimings(),
|
||||||
@@ -632,6 +1024,9 @@ export class KnowledgeSearchService {
|
|||||||
voiceCoverage.voiceCoverageComplete =
|
voiceCoverage.voiceCoverageComplete =
|
||||||
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
|
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
|
||||||
}
|
}
|
||||||
|
const indexLatestParts = partialResults
|
||||||
|
.map((result) => result.indexLatestAt)
|
||||||
|
.filter((value): value is number => typeof value === 'number' && value > 0)
|
||||||
return {
|
return {
|
||||||
state: partialResults.some((result) => result.state === 'ready')
|
state: partialResults.some((result) => result.state === 'ready')
|
||||||
? 'ready'
|
? 'ready'
|
||||||
@@ -640,6 +1035,8 @@ export class KnowledgeSearchService {
|
|||||||
: 'unavailable',
|
: 'unavailable',
|
||||||
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
|
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
|
||||||
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
|
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
|
||||||
|
// 多个分片取最新的那个:只要有一部分索引更新,整体覆盖口径就按它算。
|
||||||
|
indexLatestAt: indexLatestParts.length ? Math.max(...indexLatestParts) : null,
|
||||||
evidence: mergedEvidence,
|
evidence: mergedEvidence,
|
||||||
timings,
|
timings,
|
||||||
voiceCoverage
|
voiceCoverage
|
||||||
@@ -673,16 +1070,20 @@ export class KnowledgeSearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private listContacts(): ReturnType<typeof chat.listContactsAsync> {
|
private listContacts(lane: WcdbReadLane = 'interactive'): ReturnType<typeof chat.listContactsAsync> {
|
||||||
return this.enqueueWcdbRead(() => chat.listContactsAsync())
|
return this.enqueueWcdbRead(() => chat.listContactsAsync(), lane)
|
||||||
}
|
}
|
||||||
|
|
||||||
private listMessages(
|
private listMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number,
|
||||||
|
lane: WcdbReadLane = 'interactive'
|
||||||
): ReturnType<typeof chat.listMessagesAsync> {
|
): ReturnType<typeof chat.listMessagesAsync> {
|
||||||
return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime))
|
return this.enqueueWcdbRead(
|
||||||
|
() => chat.listMessagesAsync(conversationId, startTime, endTime, undefined, undefined, 'knowledge'),
|
||||||
|
lane
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage {
|
private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage {
|
||||||
@@ -703,8 +1104,16 @@ export class KnowledgeSearchService {
|
|||||||
const reference = this.voiceReferenceFromMessage(message)
|
const reference = this.voiceReferenceFromMessage(message)
|
||||||
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
|
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
|
||||||
const hydrated = this.withVoiceTranscript(message)
|
const hydrated = this.withVoiceTranscript(message)
|
||||||
const source = toSourceMessage(accountId, conversationId, hydrated, transcriptOverride)
|
const imageOcr = this.imageOcrResolver?.(conversationId, sourceMessageId(message))
|
||||||
if (!source || source.kind !== 'voice') return source
|
const source = toSourceMessage(
|
||||||
|
accountId,
|
||||||
|
conversationId,
|
||||||
|
hydrated,
|
||||||
|
transcriptOverride,
|
||||||
|
imageOcr
|
||||||
|
)
|
||||||
|
if (!source) return source
|
||||||
|
if (source.kind !== 'image' && source.kind !== 'voice') return source
|
||||||
return {
|
return {
|
||||||
...source,
|
...source,
|
||||||
voiceTranscriptState:
|
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> {
|
private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise<void> {
|
||||||
if (!chat.isReady()) return
|
if (!chat.isReady()) return
|
||||||
if (update.state === 'transcribed' && !update.transcript?.trim()) return
|
if (update.state === 'transcribed' && !update.transcript?.trim()) return
|
||||||
@@ -804,22 +1245,36 @@ export class KnowledgeSearchService {
|
|||||||
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
|
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
|
||||||
},
|
},
|
||||||
source: 'knowledge',
|
source: 'knowledge',
|
||||||
totalMessages: result.indexedMessageCount
|
totalMessages: result.indexedMessageCount,
|
||||||
|
sourceLatestAt: this.sourceLatestAt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evidence 的 sender 显示名 enrichment。
|
||||||
|
*
|
||||||
|
* 只做「取名字」这一件事:按 conversation 聚合 evidence 真正需要的 wxid(不是整群成员),
|
||||||
|
* 每群一次批量 name lookup(`getGroupMemberNamesAsync`),**不**构造完整 GroupSnapshot、
|
||||||
|
* **不** hydrate 头像 —— 后者会把整群成员的头像一起读出来,为拿几个名字付整群成本。
|
||||||
|
*
|
||||||
|
* 头像不属于 Query Tool 的成本;若 Evidence UI 将来要头像,走 lazy 路径。
|
||||||
|
*/
|
||||||
private async enrichEvidenceSenders(
|
private async enrichEvidenceSenders(
|
||||||
evidence: KnowledgeEvidence[],
|
evidence: KnowledgeEvidence[],
|
||||||
retrievalSessionId?: string
|
retrievalSessionId?: string
|
||||||
): Promise<KnowledgeEvidence[]> {
|
): Promise<KnowledgeEvidence[]> {
|
||||||
const candidateConversationIds = Array.from(
|
// 先按会话聚合需要的 sender,避免"每条 evidence 一次调用"。
|
||||||
new Set(
|
const wxidsByConversation = new Map<string, Set<string>>()
|
||||||
evidence
|
for (const item of evidence) {
|
||||||
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender))
|
if (!item.senderId || !looksLikeOpaqueSenderId(item.sender)) continue
|
||||||
.map((item) => item.conversationId)
|
let bucket = wxidsByConversation.get(item.conversationId)
|
||||||
)
|
if (!bucket) {
|
||||||
).slice(0, MAX_SENDER_NAME_CONVERSATIONS)
|
bucket = new Set<string>()
|
||||||
if (!candidateConversationIds.length) return evidence
|
wxidsByConversation.set(item.conversationId, bucket)
|
||||||
|
}
|
||||||
|
bucket.add(item.senderId)
|
||||||
|
}
|
||||||
|
if (!wxidsByConversation.size) return evidence
|
||||||
|
|
||||||
const session = retrievalSessionId
|
const session = retrievalSessionId
|
||||||
? this.senderEnrichmentSession(retrievalSessionId)
|
? this.senderEnrichmentSession(retrievalSessionId)
|
||||||
@@ -829,20 +1284,35 @@ export class KnowledgeSearchService {
|
|||||||
const groupConversationIds = new Set(
|
const groupConversationIds = new Set(
|
||||||
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
|
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const candidateConversationIds = Array.from(wxidsByConversation.keys())
|
||||||
|
.filter((conversationId) => groupConversationIds.has(conversationId))
|
||||||
|
.slice(0, MAX_SENDER_NAME_CONVERSATIONS)
|
||||||
|
|
||||||
const memberNamesByConversation = new Map<string, Map<string, string>>()
|
const memberNamesByConversation = new Map<string, Map<string, string>>()
|
||||||
for (const conversationId of candidateConversationIds) {
|
for (const conversationId of candidateConversationIds) {
|
||||||
if (!groupConversationIds.has(conversationId)) continue
|
const requested = Array.from(wxidsByConversation.get(conversationId) || [])
|
||||||
let snapshot = session?.groupSnapshots.get(conversationId)
|
if (!requested.length) continue
|
||||||
if (!snapshot) {
|
let memberNames = session?.groupMemberNames.get(conversationId)
|
||||||
snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId))
|
// 只查缓存里还没有的 wxid —— 同一 session 的后续 probe 因此不会重复读 WCDB。
|
||||||
session?.groupSnapshots.set(conversationId, snapshot)
|
const missing = requested.filter((wxid) => !memberNames?.has(wxid))
|
||||||
|
if (missing.length) {
|
||||||
|
const members = await this.enqueueWcdbRead(() =>
|
||||||
|
chat.getGroupMemberNamesAsync(conversationId, missing)
|
||||||
|
)
|
||||||
|
if (!memberNames) {
|
||||||
|
memberNames = new Map<string, string>()
|
||||||
|
session?.groupMemberNames.set(conversationId, memberNames)
|
||||||
|
}
|
||||||
|
for (const member of members) {
|
||||||
|
memberNames.set(member.wxid, groupMemberDisplayName(member))
|
||||||
|
}
|
||||||
|
// 请求了但没有返回名字的 wxid 也标记为"查过",避免后续 probe 反复重查。
|
||||||
|
for (const wxid of missing) {
|
||||||
|
if (!memberNames.has(wxid)) memberNames.set(wxid, '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const memberNames = new Map(
|
if (memberNames?.size) memberNamesByConversation.set(conversationId, memberNames)
|
||||||
(snapshot?.members || [])
|
|
||||||
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
|
|
||||||
.filter(([, name]) => Boolean(name))
|
|
||||||
)
|
|
||||||
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return evidence.map((item) => {
|
return evidence.map((item) => {
|
||||||
@@ -860,7 +1330,7 @@ export class KnowledgeSearchService {
|
|||||||
}
|
}
|
||||||
let session = this.senderEnrichmentSessions.get(retrievalSessionId)
|
let session = this.senderEnrichmentSessions.get(retrievalSessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
session = { lastUsedAt: now, groupSnapshots: new Map() }
|
session = { lastUsedAt: now, groupMemberNames: new Map() }
|
||||||
this.senderEnrichmentSessions.set(retrievalSessionId, session)
|
this.senderEnrichmentSessions.set(retrievalSessionId, session)
|
||||||
}
|
}
|
||||||
session.lastUsedAt = now
|
session.lastUsedAt = now
|
||||||
@@ -872,24 +1342,76 @@ export class KnowledgeSearchService {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> {
|
/**
|
||||||
const enqueuedAt = Date.now()
|
* 交互查询进行中:后台索引会让路。
|
||||||
const run = async (): Promise<T> => {
|
*
|
||||||
const startedAt = Date.now()
|
* 追赶同步会自动遍历上千个会话;如果不让路,一次用户查询会和后台 pass 抢同一个
|
||||||
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
|
* Worker 与 WCDB 读取通道,被拖到几十秒 —— 查询不能因为索引 backlog 卡住。
|
||||||
try {
|
*/
|
||||||
return await operation()
|
beginInteractiveQuery(): void {
|
||||||
} finally {
|
if (this.interactiveQueryDepth === 0) {
|
||||||
this.wcdbExecutionMsTotal += Date.now() - startedAt
|
this.interactiveIdle = new Promise<void>((resolve) => {
|
||||||
}
|
this.interactiveIdleResolve = resolve
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const result = this.wcdbReadTail.then(run, run)
|
this.interactiveQueryDepth += 1
|
||||||
// Keep the queue usable after a read failure while returning that failure to its caller.
|
}
|
||||||
this.wcdbReadTail = result.then(
|
|
||||||
() => undefined,
|
endInteractiveQuery(): void {
|
||||||
() => undefined
|
this.interactiveQueryDepth = Math.max(0, this.interactiveQueryDepth - 1)
|
||||||
)
|
if (this.interactiveQueryDepth === 0) {
|
||||||
return result
|
this.interactiveIdleResolve?.()
|
||||||
|
this.interactiveIdleResolve = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WCDB 异步分页不允许重叠,所有会话读取都必须串行。
|
||||||
|
*
|
||||||
|
* 但**后台索引**与**交互查询**不能同权排队:追赶同步会在后台遍历上千个会话,
|
||||||
|
* 交互读取排在它后面就会被拖成几十秒。因此分两条通道,交互读取优先于尚未开始的后台读取;
|
||||||
|
* 交互查询最多只等"一个正在执行的读"(WCDB 不允许重叠,这点无法避免)。
|
||||||
|
*/
|
||||||
|
private enqueueWcdbRead<T>(operation: () => Promise<T>, lane: WcdbReadLane = 'interactive'): Promise<T> {
|
||||||
|
const enqueuedAt = Date.now()
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const pending: PendingWcdbRead = {
|
||||||
|
high: lane === 'interactive',
|
||||||
|
run: async () => {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
|
||||||
|
try {
|
||||||
|
return await operation()
|
||||||
|
} finally {
|
||||||
|
this.wcdbExecutionMsTotal += Date.now() - startedAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: resolve as (value: unknown) => void,
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
if (pending.high) {
|
||||||
|
const firstBackground = this.wcdbPending.findIndex((item) => !item.high)
|
||||||
|
if (firstBackground < 0) this.wcdbPending.push(pending)
|
||||||
|
else this.wcdbPending.splice(firstBackground, 0, pending)
|
||||||
|
} else {
|
||||||
|
this.wcdbPending.push(pending)
|
||||||
|
}
|
||||||
|
this.drainWcdbReads()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private drainWcdbReads(): void {
|
||||||
|
if (this.wcdbReadBusy) return
|
||||||
|
const next = this.wcdbPending.shift()
|
||||||
|
if (!next) return
|
||||||
|
this.wcdbReadBusy = true
|
||||||
|
void next
|
||||||
|
.run()
|
||||||
|
.then(next.resolve, next.reject)
|
||||||
|
.finally(() => {
|
||||||
|
this.wcdbReadBusy = false
|
||||||
|
this.drainWcdbReads()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
|
private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
|
||||||
@@ -904,7 +1426,10 @@ export class KnowledgeSearchService {
|
|||||||
estimatedRemainingMs: null,
|
estimatedRemainingMs: null,
|
||||||
databaseBytes: 0,
|
databaseBytes: 0,
|
||||||
walBytes: 0,
|
walBytes: 0,
|
||||||
shmBytes: 0
|
shmBytes: 0,
|
||||||
|
indexLatestAt: null,
|
||||||
|
sourceLatestAt: null,
|
||||||
|
pass: this.passSnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,20 +1442,29 @@ export class KnowledgeSearchService {
|
|||||||
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
|
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
|
||||||
const current = this.statusByAccount.get(accountId)
|
const current = this.statusByAccount.get(accountId)
|
||||||
const indexing = this.indexing.has(accountId)
|
const indexing = this.indexing.has(accountId)
|
||||||
|
const pass = this.passProgress
|
||||||
const processedMessages =
|
const processedMessages =
|
||||||
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
|
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
|
||||||
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
|
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
|
||||||
const state = indexing
|
// 一遍 pass 结束后,worker 只知道「派生库能不能查」,不知道这一遍是**被取消**还是**出错**。
|
||||||
|
// 这两个语义只在这里有(worker 侧的 run_state 已经落库),所以由本地 pass 覆盖,
|
||||||
|
// 避免取消之后又冒充成一个干净的 ready。
|
||||||
|
const state: KnowledgeRuntimeState = indexing
|
||||||
? remote.indexedMessageCount > 0
|
? remote.indexedMessageCount > 0
|
||||||
? 'syncing'
|
? 'syncing'
|
||||||
: 'building'
|
: 'building'
|
||||||
: remote.state
|
: pass && (pass.phase === 'cancelled' || pass.phase === 'error')
|
||||||
|
? pass.phase
|
||||||
|
: remote.state
|
||||||
const status: KnowledgeRuntimeStatus = {
|
const status: KnowledgeRuntimeStatus = {
|
||||||
...remote,
|
...remote,
|
||||||
state,
|
state,
|
||||||
processedMessages,
|
processedMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
estimatedRemainingMs: null
|
estimatedRemainingMs: null,
|
||||||
|
// 派生库自己看不到源数据;这里补上源侧最新活跃时间,UI 才能区分 READY 与 FRESH。
|
||||||
|
sourceLatestAt: this.sourceLatestAt(),
|
||||||
|
pass: this.passSnapshot()
|
||||||
}
|
}
|
||||||
this.publishStatus(status)
|
this.publishStatus(status)
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ export class KnowledgeService {
|
|||||||
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
|
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 每个会话已经索引到的源侧时刻(epoch ms);增量 pass 用它跳过没有变化的会话。 */
|
||||||
|
highWaterMarks(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<Record<string, number>> {
|
||||||
|
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只中止正在跑的索引任务;查询请求不受影响。 */
|
||||||
|
cancelIndex(): Promise<boolean> {
|
||||||
|
return this.worker.cancelActiveIndex()
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): Promise<void> {
|
dispose(): Promise<void> {
|
||||||
return this.worker.dispose()
|
return this.worker.dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import type {
|
|||||||
KnowledgeSearchTimings,
|
KnowledgeSearchTimings,
|
||||||
KnowledgeSearchResult
|
KnowledgeSearchResult
|
||||||
} from '../../shared/knowledge'
|
} 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 { chunkConversation } from './chunker'
|
||||||
import { normalizeKnowledgeMessage } from './normalizer'
|
import { normalizeKnowledgeMessage } from './normalizer'
|
||||||
|
|
||||||
@@ -191,6 +195,7 @@ export class KnowledgeStore {
|
|||||||
async index(
|
async index(
|
||||||
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
|
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
|
||||||
sourceMessageCount?: number
|
sourceMessageCount?: number
|
||||||
|
sourceLatestAt?: number
|
||||||
},
|
},
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||||
@@ -242,6 +247,9 @@ export class KnowledgeStore {
|
|||||||
this.writeMeta('source_message_count', String(request.sourceMessageCount))
|
this.writeMeta('source_message_count', String(request.sourceMessageCount))
|
||||||
this.refreshStatsSnapshot()
|
this.refreshStatsSnapshot()
|
||||||
}
|
}
|
||||||
|
if (request.sourceLatestAt !== undefined) {
|
||||||
|
this.writeMeta('source_latest_at', String(request.sourceLatestAt))
|
||||||
|
}
|
||||||
this.setRunState('ready')
|
this.setRunState('ready')
|
||||||
return {
|
return {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
@@ -293,23 +301,64 @@ export class KnowledgeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
|
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
|
||||||
this.ensureStatsSnapshot()
|
this.ensureStatsSnapshotForQuery()
|
||||||
const indexedMessageCount = this.readStatNumber('stats_message_count')
|
const indexedMessageCount = this.readStatNumber('stats_message_count')
|
||||||
const indexedChunkCount = this.readStatNumber('stats_chunk_count')
|
const indexedChunkCount = this.readStatNumber('stats_chunk_count')
|
||||||
const runState = this.readMeta('run_state')
|
const runState = this.readMeta('run_state')
|
||||||
return {
|
return {
|
||||||
state:
|
// READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在
|
||||||
runState === 'indexing'
|
// 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。
|
||||||
? 'indexing'
|
// 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。
|
||||||
: runState === 'ready' && indexedChunkCount > 0
|
state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable',
|
||||||
? 'ready'
|
|
||||||
: 'unavailable',
|
|
||||||
indexedMessageCount,
|
indexedMessageCount,
|
||||||
indexedChunkCount,
|
indexedChunkCount,
|
||||||
|
indexLatestAt: this.readIndexLatestAt(),
|
||||||
timings: emptyKnowledgeSearchTimings()
|
timings: emptyKnowledgeSearchTimings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
|
||||||
|
*
|
||||||
|
* 优先用 per-conversation 的 `source_high_water_time` 聚合(`MAX(...)`):每个**成功处理**
|
||||||
|
* 的会话都会立刻推进并持久化它(不是等整遍 pass 结束才写),所以增量 pass 同样能让
|
||||||
|
* freshness 前进。
|
||||||
|
*
|
||||||
|
* 为什么这是源侧口径:它是会话最后活跃时间与原始消息 create_time 的最大值,对不可建模的
|
||||||
|
* 图片/空正文也照算,不是"索引里最新一条可建模消息的时间",所以不会被不可建模消息带偏;
|
||||||
|
* 并且它在 delta 空读可疑时**不推进**(安全阀在 KnowledgeSearchService 侧),
|
||||||
|
* 不会把"没读到"谎报成"已覆盖"。
|
||||||
|
*
|
||||||
|
* 不用 `source_latest_at` meta 优先:它的写入条件要求「这一遍没有任何跳过」,
|
||||||
|
* 而增量世界里"有跳过"是常态,于是它会冻结在最后一次全量 pass 的值上,
|
||||||
|
* 导致 `isKnowledgeFresh()` 恒为 false。
|
||||||
|
*
|
||||||
|
* 回退顺序:老库没有该列(或整列为 NULL)→ `source_latest_at` meta →
|
||||||
|
* `MAX(high_water_time)`(被建模消息的最新时间,只会偏旧,作下限是安全的)。
|
||||||
|
*/
|
||||||
|
private readIndexLatestAt(): number | null {
|
||||||
|
try {
|
||||||
|
const covered = this.database
|
||||||
|
.prepare('SELECT MAX(source_high_water_time) AS latest FROM knowledge_index_state')
|
||||||
|
.get() as DbRow | undefined
|
||||||
|
const value = Number(covered?.latest)
|
||||||
|
if (Number.isFinite(value) && value > 0) return value
|
||||||
|
} catch {
|
||||||
|
// 老库可能还没有 source_high_water_time 这一列(migration 之前)→ 走回退。
|
||||||
|
}
|
||||||
|
const recorded = Number(this.readMeta('source_latest_at'))
|
||||||
|
if (Number.isFinite(recorded) && recorded > 0) return recorded
|
||||||
|
try {
|
||||||
|
const row = this.database
|
||||||
|
.prepare('SELECT MAX(high_water_time) AS latest FROM knowledge_index_state')
|
||||||
|
.get() as DbRow | undefined
|
||||||
|
const value = Number(row?.latest)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getRuntimeStatus(): KnowledgeRuntimeStatus {
|
getRuntimeStatus(): KnowledgeRuntimeStatus {
|
||||||
const search = this.getSearchStatus()
|
const search = this.getSearchStatus()
|
||||||
const storage = this.getStorageStats()
|
const storage = this.getStorageStats()
|
||||||
@@ -320,7 +369,20 @@ export class KnowledgeStore {
|
|||||||
const error = this.readMeta('run_error') || undefined
|
const error = this.readMeta('run_error') || undefined
|
||||||
return {
|
return {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
state: runState === 'error' ? 'error' : search.state === 'ready' ? 'ready' : 'unavailable',
|
// 注意区分三种「不是 ready」:
|
||||||
|
// - 'error' :这一遍真的失败了;
|
||||||
|
// - 'cancelled' :用户主动取消(已提交的会话保留、可继续,绝不留一个假的 indexing);
|
||||||
|
// - 'unavailable':还没有任何可用分片。
|
||||||
|
// 这里**不**用 `search.state`(那是「能不能查」):取消后派生库仍然可查,
|
||||||
|
// 但 UI 需要区分「可用 · 已追至最新」与「可用 · 同步已取消」。
|
||||||
|
state:
|
||||||
|
runState === 'error'
|
||||||
|
? 'error'
|
||||||
|
: runState === 'cancelled'
|
||||||
|
? 'cancelled'
|
||||||
|
: search.state === 'ready'
|
||||||
|
? 'ready'
|
||||||
|
: 'unavailable',
|
||||||
indexedMessageCount: search.indexedMessageCount,
|
indexedMessageCount: search.indexedMessageCount,
|
||||||
indexedChunkCount: search.indexedChunkCount,
|
indexedChunkCount: search.indexedChunkCount,
|
||||||
sourceMessageCount,
|
sourceMessageCount,
|
||||||
@@ -330,7 +392,10 @@ export class KnowledgeStore {
|
|||||||
databaseBytes: storage.databaseBytes,
|
databaseBytes: storage.databaseBytes,
|
||||||
walBytes: storage.walBytes,
|
walBytes: storage.walBytes,
|
||||||
shmBytes: storage.shmBytes,
|
shmBytes: storage.shmBytes,
|
||||||
lastError: error
|
lastError: error,
|
||||||
|
// sourceLatestAt 属于源数据(WCDB),派生库本身看不到,由 KnowledgeSearchService 补齐。
|
||||||
|
indexLatestAt: search.indexLatestAt,
|
||||||
|
sourceLatestAt: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +410,7 @@ export class KnowledgeStore {
|
|||||||
} {
|
} {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
let ftsMs = 0
|
let ftsMs = 0
|
||||||
|
let shortTermSearchMs = 0
|
||||||
let messageLoadMs = 0
|
let messageLoadMs = 0
|
||||||
let chunkExpandMs = 0
|
let chunkExpandMs = 0
|
||||||
let rankingMs = 0
|
let rankingMs = 0
|
||||||
@@ -358,6 +424,36 @@ export class KnowledgeStore {
|
|||||||
])
|
])
|
||||||
).filter(Boolean)
|
).filter(Boolean)
|
||||||
const senderIds = new Set(query.senderIds || [])
|
const senderIds = new Set(query.senderIds || [])
|
||||||
|
if (query.conversationBoundary && conversationIds.length === 1) {
|
||||||
|
const direction = query.conversationBoundary === 'first' ? 'ASC' : 'DESC'
|
||||||
|
const row = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT conversation_id, message_id, create_time, searchable_text, kind, sender_id, sender_name
|
||||||
|
FROM knowledge_messages
|
||||||
|
WHERE conversation_id = ? AND kind <> 'system'
|
||||||
|
ORDER BY create_time ${direction}, message_id ${direction}
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(conversationIds[0]) as DbRow | undefined
|
||||||
|
const count = this.database
|
||||||
|
.prepare('SELECT COUNT(*) AS total FROM knowledge_messages WHERE conversation_id = ?')
|
||||||
|
.get(conversationIds[0]) as DbRow | undefined
|
||||||
|
const indexState = this.database
|
||||||
|
.prepare('SELECT state, complete_snapshot FROM knowledge_index_state WHERE conversation_id = ?')
|
||||||
|
.get(conversationIds[0]) as DbRow | undefined
|
||||||
|
return {
|
||||||
|
evidence: row ? [this.metadataEvidence(row)] : [],
|
||||||
|
conversationRetrieval: {
|
||||||
|
conversationId: conversationIds[0],
|
||||||
|
totalMessages: Number(count?.total || 0),
|
||||||
|
chunkCount: 0,
|
||||||
|
candidateMessages: row ? 1 : 0,
|
||||||
|
systemMessagesDeprioritized: 0,
|
||||||
|
complete: String(indexState?.state || '') === 'ready' && Number(indexState?.complete_snapshot || 0) === 1
|
||||||
|
},
|
||||||
|
timings: { ...emptyKnowledgeSearchTimings(), messageLoadMs: Date.now() - startedAt, totalMs: Date.now() - startedAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!terms.length) {
|
if (!terms.length) {
|
||||||
const messageLoadStartedAt = Date.now()
|
const messageLoadStartedAt = Date.now()
|
||||||
const metadata = this.searchByMetadata(query, conversationIds, senderIds)
|
const metadata = this.searchByMetadata(query, conversationIds, senderIds)
|
||||||
@@ -475,6 +571,7 @@ export class KnowledgeStore {
|
|||||||
timings: {
|
timings: {
|
||||||
...emptyKnowledgeSearchTimings(),
|
...emptyKnowledgeSearchTimings(),
|
||||||
ftsMs,
|
ftsMs,
|
||||||
|
shortTermSearchMs,
|
||||||
messageLoadMs,
|
messageLoadMs,
|
||||||
chunkExpandMs,
|
chunkExpandMs,
|
||||||
rankingMs,
|
rankingMs,
|
||||||
@@ -668,7 +765,8 @@ export class KnowledgeStore {
|
|||||||
evidence: asRows(
|
evidence: asRows(
|
||||||
this.database
|
this.database
|
||||||
.prepare(
|
.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
|
FROM knowledge_messages m
|
||||||
WHERE ${clauses.join(' AND ')}
|
WHERE ${clauses.join(' AND ')}
|
||||||
ORDER BY m.create_time DESC
|
ORDER BY m.create_time DESC
|
||||||
@@ -696,14 +794,31 @@ export class KnowledgeStore {
|
|||||||
timestamp: Number(row.create_time),
|
timestamp: Number(row.create_time),
|
||||||
messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId],
|
messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId],
|
||||||
sourceKind: String(row.kind) as KnowledgeEvidence['sourceKind'],
|
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
|
score: String(row.kind) === 'system' ? 1 : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
|
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
const statusStartedAt = Date.now()
|
||||||
const status = this.getSearchStatus()
|
const status = this.getSearchStatus()
|
||||||
|
const statusMs = Date.now() - statusStartedAt
|
||||||
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
|
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
|
||||||
const voiceStartedAt = Date.now()
|
const voiceStartedAt = Date.now()
|
||||||
const voiceCoverage = this.getVoiceCoverage(query)
|
const voiceCoverage = this.getVoiceCoverage(query)
|
||||||
@@ -720,6 +835,7 @@ export class KnowledgeStore {
|
|||||||
totalMs: workerExecutionMs,
|
totalMs: workerExecutionMs,
|
||||||
globalCountMs: statsRefreshMs,
|
globalCountMs: statsRefreshMs,
|
||||||
voiceCoverageMs,
|
voiceCoverageMs,
|
||||||
|
statusMs,
|
||||||
workerExecutionMs
|
workerExecutionMs
|
||||||
},
|
},
|
||||||
conversationRetrieval: measured?.conversationRetrieval,
|
conversationRetrieval: measured?.conversationRetrieval,
|
||||||
@@ -803,10 +919,20 @@ export class KnowledgeStore {
|
|||||||
attachment_json TEXT,
|
attachment_json TEXT,
|
||||||
voice_transcript TEXT,
|
voice_transcript TEXT,
|
||||||
voice_transcript_state TEXT,
|
voice_transcript_state TEXT,
|
||||||
|
image_ocr_text TEXT,
|
||||||
PRIMARY KEY (conversation_id, message_id)
|
PRIMARY KEY (conversation_id, message_id)
|
||||||
) STRICT;
|
) STRICT;
|
||||||
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
|
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
|
||||||
ON knowledge_messages (conversation_id, create_time);
|
ON knowledge_messages (conversation_id, create_time);
|
||||||
|
-- 跨会话 lexical probe 的短词回退路径是「全表 LIKE + ORDER BY create_time DESC LIMIT k」。
|
||||||
|
-- 没有这个索引时 SQLite 只能 SCAN + TEMP B-TREE,代价随表增长线性上升;
|
||||||
|
-- 有了它就能按时间倒序走索引并提前终止(同一 ORDER BY / 同一 LIMIT,结果集完全一致),
|
||||||
|
-- 降到毫秒级。它不改变任何检索语义,只是让同一条 SQL 有可用的访问路径。
|
||||||
|
CREATE INDEX IF NOT EXISTS knowledge_messages_time
|
||||||
|
ON knowledge_messages (create_time);
|
||||||
|
-- 语音覆盖聚合按 (kind, conversation_id[, create_time]) 过滤;没有它就只能全表扫。
|
||||||
|
CREATE INDEX IF NOT EXISTS knowledge_messages_kind_conversation
|
||||||
|
ON knowledge_messages (kind, conversation_id);
|
||||||
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||||
rowid INTEGER PRIMARY KEY,
|
rowid INTEGER PRIMARY KEY,
|
||||||
chunk_id TEXT NOT NULL UNIQUE,
|
chunk_id TEXT NOT NULL UNIQUE,
|
||||||
@@ -832,10 +958,18 @@ export class KnowledgeStore {
|
|||||||
state TEXT NOT NULL,
|
state TEXT NOT NULL,
|
||||||
high_water_time INTEGER,
|
high_water_time INTEGER,
|
||||||
indexed_message_count INTEGER NOT NULL DEFAULT 0,
|
indexed_message_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
complete_snapshot INTEGER NOT NULL DEFAULT 0,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
) STRICT;
|
) STRICT;
|
||||||
`)
|
`)
|
||||||
|
const stateColumns = new Set(asRows(this.database.prepare('PRAGMA table_info(knowledge_index_state)').all()).map((row) => String(row.name)))
|
||||||
|
if (!stateColumns.has('complete_snapshot')) {
|
||||||
|
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0')
|
||||||
|
}
|
||||||
|
if (!stateColumns.has('source_high_water_time')) {
|
||||||
|
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN source_high_water_time INTEGER')
|
||||||
|
}
|
||||||
const messageColumns = new Set(
|
const messageColumns = new Set(
|
||||||
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
|
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
|
||||||
String(row.name)
|
String(row.name)
|
||||||
@@ -844,6 +978,14 @@ export class KnowledgeStore {
|
|||||||
if (!messageColumns.has('voice_transcript_state')) {
|
if (!messageColumns.has('voice_transcript_state')) {
|
||||||
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN voice_transcript_state TEXT')
|
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))
|
this.writeMetaIfMissing('schema_version', String(KNOWLEDGE_SCHEMA_VERSION))
|
||||||
const storedAccount = this.readMeta('account_id')
|
const storedAccount = this.readMeta('account_id')
|
||||||
if (storedAccount && storedAccount !== this.accountId) {
|
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 rebuildStart = Math.max(0, changedAt - chunker.overlapMessages)
|
||||||
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
|
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
|
||||||
@@ -935,7 +1094,9 @@ export class KnowledgeStore {
|
|||||||
chunker.version,
|
chunker.version,
|
||||||
'indexing',
|
'indexing',
|
||||||
null,
|
null,
|
||||||
normalized.length
|
normalized.length,
|
||||||
|
null,
|
||||||
|
conversation.completeSnapshot
|
||||||
)
|
)
|
||||||
await this.writeMessageLedger(
|
await this.writeMessageLedger(
|
||||||
conversation.conversationId,
|
conversation.conversationId,
|
||||||
@@ -982,7 +1143,10 @@ export class KnowledgeStore {
|
|||||||
chunker.version,
|
chunker.version,
|
||||||
'ready',
|
'ready',
|
||||||
highWater,
|
highWater,
|
||||||
normalized.length
|
normalized.length,
|
||||||
|
null,
|
||||||
|
conversation.completeSnapshot,
|
||||||
|
conversation.sourceHighWaterTime ?? null
|
||||||
)
|
)
|
||||||
this.database.exec('COMMIT')
|
this.database.exec('COMMIT')
|
||||||
return { chunkCount: chunks.length, updatedChunks: chunks.length }
|
return { chunkCount: chunks.length, updatedChunks: chunks.length }
|
||||||
@@ -1002,8 +1166,9 @@ export class KnowledgeStore {
|
|||||||
const upsert = this.database.prepare(
|
const upsert = this.database.prepare(
|
||||||
`INSERT INTO knowledge_messages (
|
`INSERT INTO knowledge_messages (
|
||||||
account_id, conversation_id, message_id, create_time, content_hash, searchable_text,
|
account_id, conversation_id, message_id, create_time, content_hash, searchable_text,
|
||||||
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state
|
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
image_ocr_text
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
|
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
|
||||||
create_time = excluded.create_time,
|
create_time = excluded.create_time,
|
||||||
content_hash = excluded.content_hash,
|
content_hash = excluded.content_hash,
|
||||||
@@ -1013,7 +1178,8 @@ export class KnowledgeStore {
|
|||||||
sender_name = excluded.sender_name,
|
sender_name = excluded.sender_name,
|
||||||
attachment_json = excluded.attachment_json,
|
attachment_json = excluded.attachment_json,
|
||||||
voice_transcript = excluded.voice_transcript,
|
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) {
|
for (let index = 0; index < messages.length; index += 1) {
|
||||||
this.assertNotAborted(signal)
|
this.assertNotAborted(signal)
|
||||||
@@ -1030,7 +1196,8 @@ export class KnowledgeStore {
|
|||||||
message.senderName ?? null,
|
message.senderName ?? null,
|
||||||
message.attachment ? encodedJson(message.attachment) : null,
|
message.attachment ? encodedJson(message.attachment) : null,
|
||||||
message.voiceTranscript ?? null,
|
message.voiceTranscript ?? null,
|
||||||
message.voiceTranscriptState ?? null
|
message.voiceTranscriptState ?? null,
|
||||||
|
message.imageOcrText ?? null
|
||||||
)
|
)
|
||||||
if (index % YIELD_EVERY === 0) {
|
if (index % YIELD_EVERY === 0) {
|
||||||
onProgress(index + 1, 0)
|
onProgress(index + 1, 0)
|
||||||
@@ -1122,22 +1289,37 @@ export class KnowledgeStore {
|
|||||||
state: string,
|
state: string,
|
||||||
highWater: number | null,
|
highWater: number | null,
|
||||||
messageCount: number,
|
messageCount: number,
|
||||||
error: string | null = null
|
error: string | null = null,
|
||||||
|
completeSnapshot = false,
|
||||||
|
/**
|
||||||
|
* 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。
|
||||||
|
* 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉,
|
||||||
|
* 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。
|
||||||
|
*/
|
||||||
|
sourceHighWater: number | null = null
|
||||||
): void {
|
): void {
|
||||||
this.database
|
this.database
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO knowledge_index_state (
|
`INSERT INTO knowledge_index_state (
|
||||||
conversation_id, account_id, chunker_version, state, high_water_time,
|
conversation_id, account_id, chunker_version, state, high_water_time,
|
||||||
indexed_message_count, last_error, updated_at
|
indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||||
account_id = excluded.account_id,
|
account_id = excluded.account_id,
|
||||||
chunker_version = excluded.chunker_version,
|
chunker_version = excluded.chunker_version,
|
||||||
state = excluded.state,
|
state = excluded.state,
|
||||||
high_water_time = excluded.high_water_time,
|
high_water_time = excluded.high_water_time,
|
||||||
indexed_message_count = excluded.indexed_message_count,
|
indexed_message_count = excluded.indexed_message_count,
|
||||||
|
complete_snapshot = excluded.complete_snapshot,
|
||||||
last_error = excluded.last_error,
|
last_error = excluded.last_error,
|
||||||
updated_at = excluded.updated_at`
|
updated_at = excluded.updated_at,
|
||||||
|
-- 单调推进 + 保留 NULL 语义:新值为 NULL 时保持旧值;否则取两者较大者。
|
||||||
|
-- 若退化成写 0,readSourceHighWaterMarks() 的 IS NOT NULL 就会把该会话
|
||||||
|
-- 当成"有 checkpoint 但等于 0",从而每遍都误走 backfill 全量读。
|
||||||
|
source_high_water_time = CASE
|
||||||
|
WHEN excluded.source_high_water_time IS NULL THEN knowledge_index_state.source_high_water_time
|
||||||
|
ELSE MAX(COALESCE(knowledge_index_state.source_high_water_time, 0), excluded.source_high_water_time)
|
||||||
|
END`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -1146,11 +1328,33 @@ export class KnowledgeStore {
|
|||||||
state,
|
state,
|
||||||
highWater,
|
highWater,
|
||||||
messageCount,
|
messageCount,
|
||||||
|
completeSnapshot ? 1 : 0,
|
||||||
error,
|
error,
|
||||||
Date.now()
|
Date.now(),
|
||||||
|
sourceHighWater
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个会话「已经索引到源数据的哪个时刻」(epoch ms)。
|
||||||
|
*
|
||||||
|
* 增量 pass 用它判断哪些会话真的需要重新读取:Session 行的 `last_timestamp` 不晚于这个值
|
||||||
|
* 就说明没有新消息,可以直接跳过(不读 WCDB、不写索引)。
|
||||||
|
*/
|
||||||
|
readSourceHighWaterMarks(): Record<string, number> {
|
||||||
|
const marks: Record<string, number> = {}
|
||||||
|
for (const row of asRows(
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
'SELECT conversation_id, source_high_water_time FROM knowledge_index_state WHERE source_high_water_time IS NOT NULL'
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)) {
|
||||||
|
marks[String(row.conversation_id)] = Number(row.source_high_water_time)
|
||||||
|
}
|
||||||
|
return marks
|
||||||
|
}
|
||||||
|
|
||||||
private readMeta(key: string): string | null {
|
private readMeta(key: string): string | null {
|
||||||
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
|
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
|
||||||
| DbRow
|
| DbRow
|
||||||
@@ -1184,6 +1388,43 @@ export class KnowledgeStore {
|
|||||||
this.refreshStatsSnapshot()
|
this.refreshStatsSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索热路径专用的快照读取(**不做全表聚合**)。
|
||||||
|
*
|
||||||
|
* 分开的原因:`markStatsStale()` 在**每次** `index()` 调用时都会执行,而
|
||||||
|
* `KnowledgeSearchService.indexAccount` 是**逐会话**调用 `index()` 的,所以一遍后台 pass
|
||||||
|
* 进行中 `stats_state` 几乎永远是 `'stale'`,pass 被中断后更是会一直留在 `'stale'`。
|
||||||
|
* 如果每次搜索都因此重做三次全表聚合(messages / chunks / voice),单个 probe 的代价就是
|
||||||
|
* 数十秒级,交互查询会被卡住。
|
||||||
|
*
|
||||||
|
* 规则:只有在快照**从未建立**(首库)或**明显与真实数据不符**(快照说 0 个分片、
|
||||||
|
* 但库里确实有分片 → 会把可查询的库误报成 unavailable)时才刷新。其余情况沿用上一次完整
|
||||||
|
* pass 写下的结论:数字可能略旧,但不会把可查询的库说成不可用,也不会把交互查询卡在
|
||||||
|
* 全表聚合上。
|
||||||
|
*/
|
||||||
|
private ensureStatsSnapshotForQuery(): void {
|
||||||
|
const state = this.readMeta('stats_state')
|
||||||
|
if (state === 'fresh') return
|
||||||
|
if (state === null) {
|
||||||
|
this.refreshStatsSnapshot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.readStatNumber('stats_chunk_count') === 0 && this.hasAnyChunk()) {
|
||||||
|
this.refreshStatsSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只探一行,用于避免把"有分片但快照过期"的库报成不可用。 */
|
||||||
|
private hasAnyChunk(): boolean {
|
||||||
|
try {
|
||||||
|
return Boolean(
|
||||||
|
this.database.prepare('SELECT 1 AS present FROM knowledge_chunks LIMIT 1').get()
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private markStatsStale(): void {
|
private markStatsStale(): void {
|
||||||
this.writeMeta('stats_state', 'stale')
|
this.writeMeta('stats_state', 'stale')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type WorkerResult =
|
|||||||
| KnowledgeCapacityPreflight
|
| KnowledgeCapacityPreflight
|
||||||
| KnowledgeSearchResult
|
| KnowledgeSearchResult
|
||||||
| KnowledgeRuntimeStatus
|
| KnowledgeRuntimeStatus
|
||||||
|
| { marks: Record<string, number> }
|
||||||
| { removed: true }
|
| { removed: true }
|
||||||
type PendingRequest = {
|
type PendingRequest = {
|
||||||
resolve: (result: WorkerResult) => void
|
resolve: (result: WorkerResult) => void
|
||||||
@@ -36,6 +37,14 @@ export class KnowledgeWorkerHost {
|
|||||||
private child: ChildProcess | null = null
|
private child: ChildProcess | null = null
|
||||||
private childStartedAt = 0
|
private childStartedAt = 0
|
||||||
private readonly pending = new Map<string, PendingRequest>()
|
private readonly pending = new Map<string, PendingRequest>()
|
||||||
|
/**
|
||||||
|
* 当前在跑的索引请求 id。
|
||||||
|
*
|
||||||
|
* 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 ——
|
||||||
|
* 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能
|
||||||
|
* 精确地只中止索引,而**不会**影响任何并发进行的查询请求。
|
||||||
|
*/
|
||||||
|
private activeIndexRequestId: string | null = null
|
||||||
|
|
||||||
constructor(private readonly workerPath: string) {}
|
constructor(private readonly workerPath: string) {}
|
||||||
|
|
||||||
@@ -43,7 +52,9 @@ export class KnowledgeWorkerHost {
|
|||||||
payload: KnowledgeIndexRequest,
|
payload: KnowledgeIndexRequest,
|
||||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||||
): Promise<KnowledgeIndexResult> {
|
): Promise<KnowledgeIndexResult> {
|
||||||
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult>
|
return this.request('index', payload, onProgress, (requestId) => {
|
||||||
|
this.activeIndexRequestId = requestId
|
||||||
|
}) as Promise<KnowledgeIndexResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
|
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
|
||||||
@@ -58,6 +69,21 @@ export class KnowledgeWorkerHost {
|
|||||||
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
|
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 每个会话已经索引到的源侧时刻;用于增量 pass 跳过没有变化的会话。 */
|
||||||
|
highWaterMarks(payload: KnowledgeStatusRequest): Promise<Record<string, number>> {
|
||||||
|
return this.request('highWater', payload as unknown as KnowledgeWorkerRequest['payload']).then(
|
||||||
|
(result) => ('marks' in result ? result.marks : {})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
||||||
|
async cancelActiveIndex(): Promise<boolean> {
|
||||||
|
const target = this.activeIndexRequestId
|
||||||
|
if (!target) return false
|
||||||
|
await this.cancel(target)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
|
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
|
||||||
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
|
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
|
||||||
}
|
}
|
||||||
@@ -81,13 +107,15 @@ export class KnowledgeWorkerHost {
|
|||||||
private request(
|
private request(
|
||||||
type: KnowledgeWorkerRequest['type'],
|
type: KnowledgeWorkerRequest['type'],
|
||||||
payload: KnowledgeWorkerRequest['payload'],
|
payload: KnowledgeWorkerRequest['payload'],
|
||||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
onProgress?: (progress: KnowledgeIndexProgress) => void,
|
||||||
|
onRequestId?: (requestId: string) => void
|
||||||
): Promise<WorkerResult> {
|
): Promise<WorkerResult> {
|
||||||
const hadWorker = Boolean(this.child?.connected)
|
const hadWorker = Boolean(this.child?.connected)
|
||||||
const child = this.ensureChild()
|
const child = this.ensureChild()
|
||||||
const requestId = randomUUID()
|
const requestId = randomUUID()
|
||||||
const sentAt = Date.now()
|
const sentAt = Date.now()
|
||||||
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
|
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
|
||||||
|
onRequestId?.(requestId)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.pending.set(requestId, {
|
this.pending.set(requestId, {
|
||||||
resolve,
|
resolve,
|
||||||
@@ -145,6 +173,7 @@ export class KnowledgeWorkerHost {
|
|||||||
const pending = this.pending.get(requestId)
|
const pending = this.pending.get(requestId)
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
this.pending.delete(requestId)
|
this.pending.delete(requestId)
|
||||||
|
if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null
|
||||||
if (error) pending.reject(error)
|
if (error) pending.reject(error)
|
||||||
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
|
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
|
||||||
else pending.reject(new Error('Knowledge worker returned no result'))
|
else pending.reject(new Error('Knowledge worker returned no result'))
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ async function handleSearch(
|
|||||||
evidence: [],
|
evidence: [],
|
||||||
indexedMessageCount: 0,
|
indexedMessageCount: 0,
|
||||||
indexedChunkCount: 0,
|
indexedChunkCount: 0,
|
||||||
|
indexLatestAt: null,
|
||||||
timings: emptyKnowledgeSearchTimings()
|
timings: emptyKnowledgeSearchTimings()
|
||||||
},
|
},
|
||||||
workerReceivedAt,
|
workerReceivedAt,
|
||||||
@@ -153,7 +154,9 @@ async function handleStatus(
|
|||||||
estimatedRemainingMs: null,
|
estimatedRemainingMs: null,
|
||||||
databaseBytes: 0,
|
databaseBytes: 0,
|
||||||
walBytes: 0,
|
walBytes: 0,
|
||||||
shmBytes: 0
|
shmBytes: 0,
|
||||||
|
indexLatestAt: null,
|
||||||
|
sourceLatestAt: null
|
||||||
}
|
}
|
||||||
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||||
return
|
return
|
||||||
@@ -166,6 +169,24 @@ async function handleStatus(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个会话「已经索引到源数据的哪个时刻」。
|
||||||
|
* 增量 pass 靠它决定哪些会话可以整段跳过(见 `KnowledgeStore.readSourceHighWaterMarks`)。
|
||||||
|
*/
|
||||||
|
async function handleHighWater(
|
||||||
|
request: KnowledgeWorkerRequest,
|
||||||
|
payload: KnowledgeStatusRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||||
|
const marks = existsSync(path) ? getStore(payload).readSourceHighWaterMarks() : {}
|
||||||
|
send({
|
||||||
|
version: 1,
|
||||||
|
type: 'result',
|
||||||
|
requestId: request.requestId,
|
||||||
|
payload: { marks } as unknown as KnowledgeWorkerResponse['payload']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (request.type === 'cancel') {
|
if (request.type === 'cancel') {
|
||||||
@@ -201,6 +222,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
|||||||
await handleStatus(request, request.payload as KnowledgeStatusRequest)
|
await handleStatus(request, request.payload as KnowledgeStatusRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (request.type === 'highWater') {
|
||||||
|
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (request.type === 'index') {
|
if (request.type === 'index') {
|
||||||
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ export function normalizeKnowledgeMessage(
|
|||||||
const transcript = compact(source.voiceTranscript)
|
const transcript = compact(source.voiceTranscript)
|
||||||
if (transcript) sections.push(`语音转写:${transcript}`)
|
if (transcript) sections.push(`语音转写:${transcript}`)
|
||||||
|
|
||||||
|
// 图片 OCR 文本:与语音同样的"固定前缀"约定,让检索与展示都能识别这是派生内容。
|
||||||
|
const imageText = compact(source.imageOcrText)
|
||||||
|
if (imageText) sections.push(`图片文字:${imageText}`)
|
||||||
|
|
||||||
const attachmentName = compact(source.attachment?.name)
|
const attachmentName = compact(source.attachment?.name)
|
||||||
if (attachmentName) {
|
if (attachmentName) {
|
||||||
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
|
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
|
||||||
@@ -45,6 +49,7 @@ export function normalizeKnowledgeMessage(
|
|||||||
senderId: source.senderId || '',
|
senderId: source.senderId || '',
|
||||||
kind: source.kind,
|
kind: source.kind,
|
||||||
voiceTranscriptState: source.voiceTranscriptState || '',
|
voiceTranscriptState: source.voiceTranscriptState || '',
|
||||||
|
imageOcrState: source.imageOcrState || '',
|
||||||
searchableText
|
searchableText
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user