Compare commits

...
9 Commits
74 changed files with 5593 additions and 644 deletions
+30 -20
View File
@@ -25,16 +25,14 @@
</p>
<p align="center">
<img src="./public/日报.png" alt="TraceMemo 主界面" />
<img src="./public/日报.png" alt="TraceMemo 日报" />
</p>
<p align="center">
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
<img src="./public/自动化.png" alt="TraceMemo 自动化" />
</p>
<p align="center">
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
</p>
---
## 🎨 社区日报模板
@@ -49,7 +47,7 @@ TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多
在 TraceMemo 中打开:
**日报 → 今日日报 → 日报模板 → 模板市场**
**日报 → 社区模板市场**
即可查看、预览、安装和切换已发布的社区模板。
@@ -67,18 +65,21 @@ TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数
## 核心能力
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
- 💬 **聊天档案与搜索**:浏览会话,按关键词、备注、昵称或 wxid 查找消息。
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
- 🧠 **本地知识库**:在本机建立索引,让跨会话、跨时间的查询更稳定。
- 🖼️ **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图),识别结果可以在搜索和「问问微信」里被检索。识别全程不联网,原始图片不会因为本地识别而上传。
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结,可保存为 HTML 与 PNG。
- 🗣️ **群发言统计**:统计群成员的发言量和沉默成员,看清一个群里谁在说、谁一直没说。
- 👀 **退群监控**:用成员快照对比记录群成员退出事件,支持多群与事件历史。
- ⚙️ **自动化**:把上面几步按规则串起来——定时生成并发送日报、成员退群时发送通知;能发到哪里取决于当前的发送能力。
- 🔊 **文字转语音**:把文字生成语音,试听后发送到当前会话。
- 🤖 **Agent Hub**:在微信里向本机 TraceMemo 提问。
- 🔌 **外部 Agent / Local HTTP API**:让 Codex 等外部 Agent 查询本机微信历史。
## 💻 平台支持
TraceMemo 2.4.0 支持:
TraceMemo 2.5.0 支持:
- **Windows x64**
- **macOS Apple Silicon(M 系列 / arm64)**
@@ -86,6 +87,12 @@ TraceMemo 2.4.0 支持:
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
### 关于“发送能力”
浏览、搜索、日报生成、导出、知识库和图片文字索引都不需要额外的发送组件。只有**把内容真正发回微信**这一步——自动发送日报、退群通知、把语音发到会话——依赖本机发送能力:
发送能力未就绪、未绑定或发送失败时,报告本身仍会正常生成并保存在本机,执行记录会显示为“已生成,但未发送”或“已生成,发送失败”,可以稍后重试。
## 项目缘起
<details>
@@ -138,11 +145,14 @@ TraceMemo 最早叫 **WechatExplorer**。
| 想做什么 | 使用入口 |
| ---------------------------------- | ----------------------------- |
| 找记得原文或关键词的消息 | 档案搜索 |
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
| 找记得大意、但不知道在哪聊过的内容 | 问问微信(AI Search) |
| 找到截图、公告图里写过的文字 | 问问微信 → 图片文字索引 |
| 长期跨群查询历史 | 本地知识库 |
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
| 了解一个群今天或近 7 天聊了什么 | 日报 |
| 看群里谁最活跃、谁一直没说话 | 档案 → 群聊 → 群发言统计 |
| 持续关注群成员退出 | 退群监控 |
| 按计划生成并发送群聊日报 | 定时日报 |
| 按计划自动生成并发送群聊日报 | 自动化 |
| 成员退群时自动发一条通知 | 自动化 → 退群通知 |
| 把文字生成微信语音 | 文字转语音 |
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
@@ -159,9 +169,9 @@ TraceMemo 最早叫 **WechatExplorer**。
## 文档
- [用户指南](./docs/README.md#用户指南)
- [用户指南](./docs/README.md#档案与搜索)
- [AI / Knowledge](./docs/README.md#ai-与知识库)
- [Monitor / Automation](./docs/README.md#日报与自动化)
- [日报与自动化](./docs/README.md#日报与自动化)
- [Agent / API](./docs/README.md#agent--api)
- [开发文档](./docs/development/overview.md)
- [隐私与安全](./docs/user-guide/privacy.md)
@@ -213,7 +223,7 @@ TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.co
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
+7 -7
View File
@@ -6,29 +6,29 @@
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口,以及群聊里的「群发言统计」。
## AI 与知识库
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性;也包括在本机识别图片文字、让截图和公告图变得可搜索的「图片文字索引」。
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
## 日报与自动化
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以在「自动化」里创建定时日报。
- 「自动化」按三类规则执行:**@我生成日报**、**定时日报**、**退群通知**。定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
## Monitor
## 退群监控
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。成员退出同时是「自动化 → 退群通知」的触发条件。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
## 语音能力
- [语音转文字](./user-guide/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊;实际发送依赖本机发送能力。
## Agent / API
+12 -11
View File
@@ -21,14 +21,15 @@ Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导
- “帮我看看最近跟某人聊了些什么。”
- “生成产品交流群今天的群聊总结图片。”
当前已实现的实时任务包括:
Hub 把入站文字分成两类处理。
- 查看最近会话(数量限制为 1–20);
- 查询你和某位联系人的近期聊天;
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
- 生成今天、昨天或近 7 天的群聊总结图片;
- 总结指定群成员在群里的近期发言;
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
**确定性的快捷动作**(不经过模型,命中就执行):
- 查看最近会话:数量限制为 1–20;
- 生成群聊总结图片:今天、昨天或近 7 天(需要同时提到“群”和“图片 / 长图 / 日报 / 报告”);
- 分析某个群成员的近期发言:可以指定“今天 / 昨天 / 最近 N 天”。
**其余问题**交给本机的 Query Agent:它和桌面端“问问微信”使用的是同一个实现,可以按需读取联系人、会话和时间范围来回答,必要时调用你在“设置 → AI 模型”里配置的 AI。例如“帮我看看最近跟某人聊了些什么”“上个月讨论过的项目地址在哪里”。
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
@@ -56,12 +57,12 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
## 安全与边界
- Hub 使用本机通信,不把数据库直接暴露到公网;
- Hub 在主进程内运行,不开放本地监听端口;它不会把数据库暴露到公网;
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
- Hub 理解请求或生成总结时,会调用你在“设置 → AI 模型”配置的 Provider;
- 当前实时入口只处理文字消息。连接器会归一化收到的消息条目,但 Agent Hub 只把文本条目当作意图处理,尚未为图片、语音、文件和视频提供同等能力;
- 当前没有实现群发、广播、定时任务或通用自主操作微信(定时日报属于「自动化」,不是 Agent Hub);
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
## 无法连接时
+5 -2
View File
@@ -4,9 +4,11 @@
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
它同时包含**写入型**端点:生成报告并渲染 PNG(`/report`)、通过已连接机器人发送微信消息(`/agent/send`)、创建/修改/删除/启停定时日报任务并触发立即执行(`/scheduled-reports*`)。因此这个 Token 相当于本机敏感凭据,而不是一个只读查询键。
## Bearer Token
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。历史变量名 `WECHATEXPLORER_API_TOKEN` 仍被兼容读取,优先级为新变量高于旧变量;当前没有设定旧变量名的移除时间,新配置不要再使用它。
- `/api/v1/health` 是公开健康检查;
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
@@ -14,7 +16,8 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
- 文件权限设置为 `0600`;
- 在“API Center”中可以显示、复制和重新生成;
- 重新生成后旧 Token 立即失效。
- 重新生成后旧 Token 立即失效;
- 服务端只认这个 Token,**不接受用环境变量覆盖**——Agent 一侧的环境变量只是把 Token 交给 Agent 自己的方式,不是鉴权来源。
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
+146 -5
View File
@@ -8,7 +8,9 @@
- API 前缀:`/api/v1`
- 默认只监听 loopback;不要把它当作公网服务。
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`。
- 请求体使用 JSON;响应为 JSON。
- 请求体使用 JSON,单个请求体最大 `1 MiB`;超限返回 `413`。
- 错误响应包含 `requestId`,响应头包含 `X-Request-Id`。客户端可传入 1-128 位的 `[A-Za-z0-9._:-]` 标识,否则服务端会生成 UUID。
- 不支持的 HTTP method 返回 `405` 和 `Allow` 响应头。
## 最小请求
@@ -24,12 +26,14 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。应用生成的安装指令仍会提示:尚未升级的旧配置可以继续读取 `WECHATEXPLORER_API_TOKEN`,但新配置必须使用新变量名;如果两个变量都存在,以新变量为准。当前没有设定旧变量名的移除时间。
Token 由应用生成并保存在本机,**不接受用环境变量覆盖**:Agent 侧的环境变量只是把 Token 传给 Agent 自己的方式,不是服务端的鉴权来源。
## 端点
| 方法 | 路径 | 作用 | 参数/请求体 |
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| ------ | --------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
@@ -43,12 +47,147 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信发送能力状态 | 无 |
| GET | `/api/v1/scheduled-reports` | 定时日报任务列表 | 无 |
| POST | `/api/v1/scheduled-reports` | 创建定时日报任务 | `ScheduledReportApiCreateRequest` JSON |
| GET | `/api/v1/scheduled-reports/{id}` | 查询单个定时日报任务 | 无 |
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改定时日报任务 | `ScheduledReportApiUpdateRequest` JSON |
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除定时日报任务 | 无 |
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用定时日报任务 | 无 |
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停定时日报任务 | 无 |
| POST | `/api/v1/scheduled-reports/{id}/run` | 立即执行一次并返回 execution | 无 |
| GET | `/api/v1/scheduled-reports/{id}/executions` | 查询某个任务的执行记录 | 无 |
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 兼容占位路由;当前返回 `501 not_supported` | 无 |
| GET | `/api/v1/capabilities` | TraceMemo 应用能力和可用状态 | 无 |
| GET | `/api/v1/automations` | 自动化规则列表 | 可选 `type`、`enabled` |
| POST | `/api/v1/automations` | 创建默认停用的自动化规则 | Automation draft JSON |
| POST | `/api/v1/automations/validate` | 校验规则,不保存、不执行 | Automation draft JSON |
| GET | `/api/v1/automations/{id}` | 查询单条自动化规则 | 无 |
| PATCH | `/api/v1/automations/{id}` | 更新规则配置 | 可变配置字段 JSON |
| DELETE | `/api/v1/automations/{id}` | 删除自定义规则 | 系统内置规则受保护 |
| POST | `/api/v1/automations/{id}/enable` | 校验并启用规则 | 无 |
| POST | `/api/v1/automations/{id}/disable` | 停用规则 | 无 |
| GET | `/api/v1/automations/executions` | 查询自动化执行记录 | `ruleId`、`status`、`since`、`until`、`limit` |
| GET | `/api/v1/monitors/group-exits` | 查看退群监控状态 | 无 |
| PATCH | `/api/v1/monitors/group-exits` | 配置监控群范围或启停 | `enabled`、`monitoredConversationIds` |
| GET | `/api/v1/monitors/group-exits/events` | 查询退群事件历史 | `conversationId`、`since`、`until`、`limit` |
| GET | `/api/v1/groups/{conversationId}/member-stats` | 查询群成员活跃统计 | 必填 `conversationId`、`start`、`end` |
`/api/v1/query/*` 是一组结构化的 Query 端点,见下方[LLM-friendly Query Tool API](#llm-friendly-query-tool-api)。
## Application Capabilities
`GET /api/v1/capabilities` 描述 TraceMemo 应用级能力和当前运行环境;`GET /api/v1/query/capabilities` 只描述结构化 Query primitive,两者不是同一份目录。应用能力使用 `supported` 和 `available` 分开表示“代码支持”与“当前可用”;运行时原因使用稳定的简短 code,不返回 Token、数据库路径、微信密钥或 sender 诊断路径。
响应包含应用版本、数据库 readiness、Query、Automation、退群监控、群统计,以及个人微信/iLink 的能力状态。`groupExitMonitor.operations` 当前声明 `read_state`、`configure_scope`、`enable`、`disable`、`list_events`;`groupStats.operations` 当前声明 `member_stats`。能力声明不会触发监控扫描或群统计查询。
```bash
: "${TRACEMEMO_API_TOKEN:?Set TRACEMEMO_API_TOKEN from API Center}"
BASE="http://127.0.0.1:6131/api/v1"
AUTH="Authorization: Bearer $TRACEMEMO_API_TOKEN"
curl -H "$AUTH" "$BASE/capabilities"
```
## Group Exit Monitor API
退群监控只负责“监测哪些群、发现了哪些退群事实”。退群后是否通知、通知到哪里以及通知模板,仍由 `leave_notification` Automation singleton 负责;修改监控范围不会隐式修改该 Automation。
### 查看和配置监控
```bash
curl -H "$AUTH" "$BASE/monitors/group-exits"
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
"$BASE/monitors/group-exits" \
-d '{"enabled":true,"monitoredConversationIds":["123@chatroom"]}'
```
`monitoredConversationIds` 只接受当前联系人列表中精确存在的群 `roomId`(例如 `xxx@chatroom`),不接受群名、md5、个人联系人、重复或空 ID。请求至少提供 `enabled` 或 `monitoredConversationIds` 其中一个;传空数组表示清空监控范围。服务会先校验全部群,再执行一次原子配置。PATCH 返回最终完整状态。
状态中的 `eventCount` 是持久化退群事件总数,`lastCheckedAt`/`lastReadAt` 为空时返回 `null`。GET 不会调用 `checkNow()`,也不会触发通知发送。
### 查询退群事件
```bash
curl -G -H "$AUTH" "$BASE/monitors/group-exits/events" \
--data-urlencode 'conversationId=123@chatroom' \
--data-urlencode 'since=2026-10-01T00:00:00+07:00' \
--data-urlencode 'until=2026-10-02T23:59:59+07:00' \
--data-urlencode 'limit=50'
```
时间参数必须是带 offset 的 ISO-8601;默认 `limit=50`,最大 200。事件按 `detectedAt` 升序返回。事件 DTO 使用 `eventId`、稳定的 `conversationId` 和 `memberId`,并把时间输出为 ISO-8601;当前整体已读状态不会伪造成 event-level `read` 字段。当前未开放 clear events、markRead 或 checkNow HTTP 路由。
一个典型 Agent 工作流是:先通过 `/resolve` 或 `/contact` 找到稳定群 ID,再 PATCH monitor scope;如需通知,再单独 PATCH `leave_notification` Automation,调用 `/automations/validate`,最后启用规则。
## Group Member Stats API
```bash
curl -G -H "$AUTH" "$BASE/groups/123%40chatroom/member-stats" \
--data-urlencode 'start=2026-09-01T00:00:00+07:00' \
--data-urlencode 'end=2026-10-01T00:00:00+07:00'
```
`conversationId` 必须是当前联系人列表中精确存在的群 `roomId`;不存在返回 `NOT_FOUND`,个人联系人返回 `NOT_GROUP_CONVERSATION`。`start` 和 `end` 必须同时提供,且使用带 offset 的 ISO-8601,`start` 不能晚于 `end`。HTTP adapter 只负责把稳定群 ID 解析为内部 md5 并调用现有 `GroupStatsService`,不会在 HTTP 层重新统计消息。
响应中的 `activeMembers` 和 `silentMembers` 都只描述当前成员名单;成员使用 `memberId`,活跃成员的 `lastMessageAt` 和 `range` 时间均为 ISO-8601。`freshness`、`complete`、`limitations` 必须原样保留,`unattributedMessages` 与 `excludedSystemMessages` 用于诊断,`firstMessageAt` 没有消息时为 `null`。当前成员统计不等于完整历史成员统计,`limitations` 表达的退群成员或未归档时段不能从文本中推导成额外的 `formerMembers`,也不会伪造 `totalMessageCount`。
## Automation API
`/api/v1/automations*` 是 Automation 的 canonical HTTP API,读写唯一的 `AutomationRuleStore`。它支持当前真实规则类型:`daily_report`、`scheduled_report`、`leave_notification`。本 API 不提供立即执行、重试或清理执行记录。
旧 `/api/v1/scheduled-reports*` 保持兼容,不设移除日期;它是面向旧 DTO 的受限 compatibility API,不是第二份存储,也不能表示所有新的定时日报目标和配置。新的 Agent 集成应使用 `/automations`。
创建和校验规则时 `enabled` 只能缺省或为 `false`。创建成功后必须调用 `/automations/{id}/enable` 才会启用。启用会重新校验当前规则;数据库未就绪、目标无法解析或配置无效时不会启用。`PATCH` 只接受规则配置字段,不可改 `ruleType`、`id`、创建/更新时间或 `enabled`;启停必须使用独立 endpoint。未知字段和未知枚举会被拒绝。
会话范围优先传 `wxid`、`roomId`(如 `xxx@chatroom`)或 canonical conversation ID。唯一匹配的联系人名可被解析为稳定 ID;重名会返回 `ambiguous_contact`,不会猜测。`daily_report.conditions.conversationIds` 在对外 API 中使用稳定 ID,Store 内部仍沿用既有 md5 口径。
校验请求不落盘、不发消息,也不执行规则。`valid: false` 时查看 `issues`;有效时 `normalized` 是经 ID 解析后的草稿,`effects` 描述启用后的动作,定时日报另外返回按本机时区计算的 `nextRunAt`。
```json
{
"name": "产品群每日日报",
"ruleType": "scheduled_report",
"scheduledReport": {
"schedule": { "time": "20:00" },
"report": {
"sourceConversationId": "wxid_product@chatroom",
"range": "today",
"messageTypes": ["text", "image"],
"templateId": "v1",
"memberNameMode": "groupNickname",
"timeoutSeconds": 300
},
"target": { "type": "file_transfer" },
"postfixText": ""
}
}
```
执行历史只读,默认最多返回 50 条,`limit` 范围是 1-200。`since` 和 `until` 接受带时区的 ISO-8601 时间;execution 本身最多留存 200 条。`running` 记录的 `finishedAt` 为 `null`。
新 Agent API 的错误格式:
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "自动化规则校验失败",
"details": []
},
"requestId": "..."
}
```
常见错误码包括 `UNAUTHORIZED`、`METHOD_NOT_ALLOWED`、`PAYLOAD_TOO_LARGE`、`INVALID_ARGUMENT`、`NOT_FOUND`、`NOT_GROUP_CONVERSATION`、`DATABASE_NOT_READY`、`VALIDATION_FAILED`、`SINGLETON_RULE`、`PROTECTED_RULE` 和 `PERSISTENCE_FAILED`。`leave_notification` 是固定单例:可读取、修改和启停,但不能创建第二条或删除。内置 `@我生成日报` 同样不能通过 HTTP 删除。
### 这些端点与实时机器人有什么关系
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
- `/api/v1/agent/send` 是受 Bearer Token 保护的开发者/测试发送入口,用于通过已经连接的机器人发送文字或本地图片;它不是任意群发能力,也不是实时消息订阅接口;
- `/api/v1/scheduled-reports*` 会**写入**应用状态:创建、修改、删除、启停定时日报任务,以及立刻执行一次。加上 `/report` 和 `/agent/send`,这个 API 并非只读接口——拿到 Token 就能改配置、生成报告并发送微信消息,请按本机敏感凭据对待;
- `POST /api/v1/scheduled-reports/{id}/run` 与定时触发共用同一条链路:读取群聊 → 生成报告 → 保存 Report History → 尝试发送;
- 当前 API 没有对外暴露实时入站 webhook。微信消息由应用内部的 Agent Hub 和微信连接器接收、处理和回复。
## 时间查询
@@ -83,10 +222,12 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
## 响应与错误
- `200`:请求成功;
- `201`:定时日报任务创建成功;
- `401`:缺少、错误或已失效的 Bearer Token;
- `400`:参数或 JSON 请求体无效;
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
- `403`:浏览器 Origin 不在允许的 loopback 列表;
- `409`:定时日报任务重复(`error === "duplicate"`,响应里会带回已存在的任务),或群聊名称匹配到多个目标(`ambiguous_contact`);
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
- `503`:数据库或 Agent Hub 尚未就绪;
- `500`:服务端处理或报告渲染失败。
@@ -155,7 +296,7 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
| scope | 含义 |
| ----- | ---- |
| ----------------------------------------- | -------------------------------------------------------------------- |
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
@@ -187,7 +328,7 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
| 字段 | 含义 |
| ---- | ---- |
| ------------------- | ------------------------------------------------------------------- |
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
+6 -1
View File
@@ -8,7 +8,7 @@ Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Cod
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 仍可继续使用旧变量 `WECHATEXPLORER_API_TOKEN`(当前没有设定移除时间),但新安装请使用新名称与新变量名。
## 推荐安装流程
@@ -53,8 +53,13 @@ Reader Skill 可以指导 Agent 使用:
- 指定会话、日期或时间戳范围的聊天记录;
- 群成员快照;
- 结构化日报渲染和按群聊生成总结图片;
- 定时日报任务的查询、创建、修改、启停、删除、立即执行和执行记录;删除不可逆,Skill 要求先列出唯一任务并取得用户明确确认;
- 个人微信发送能力状态查询(`/wechat-personal/send-capability`);
- `query/*` 一组结构化 Query 端点:`messages`、`search`、`message-context`、`conversation-overview`;
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
注意这个 API 不只是只读的:`/report`、`/agent/send` 和 `/scheduled-reports*` 会写入状态或真的发出微信消息。
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
## 隐私边界
+50 -28
View File
@@ -4,39 +4,54 @@
```mermaid
flowchart LR
A[本机微信数据] --> B[读取与解析]
B --> C[聊天档案与普通搜索]
B --> D[本地知识索引]
D --> E[筛选相关消息]
E --> F[用户配置的 AI Provider]
F --> G[回答与可核对来源]
B --> H[聊天导出]
B --> I[整理日报输入]
I --> F
F --> J[本地保存 HTML 与 PNG]
B --> K[Local HTTP API]
K --> L[外部 Agent]
M[微信机器人消息] --> N[Agent Hub]
N --> B
N --> F
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]
WX["本机微信数据"] --> PARSE["读取与解析"]
PARSE --> ARCHIVE["聊天档案与普通搜索"]
PARSE --> EXPORT["聊天导出"]
PARSE --> IDX["本机索引"]
IDX --> TEXTIDX["聊天记录索引"]
IDX --> IMGIDX["图片文字索引(本机识别)"]
TEXTIDX --> UNDERSTAND["Understand:AI Search / 问问微信"]
IMGIDX --> UNDERSTAND
UNDERSTAND --> PROVIDER["你配置的 AI Provider"]
PROVIDER --> ANSWER["回答与可核对来源"]
PARSE --> REPORTINPUT["整理日报输入"]
REPORTINPUT --> PROVIDER
PROVIDER --> REPORTFILE["本机保存 HTML 与 PNG"]
PARSE --> MONITOR["Monitor:退群监控 / 成员快照"]
MONITOR --> RULE["自动化规则"]
REPORTFILE --> RULE
RULE --> POLICY["Policy"]
POLICY --> GATEWAY["Action Gateway"]
GATEWAY --> CAP["本机发送能力"]
CAP --> AUDIT["执行记录与审计"]
PARSE --> API["Local HTTP API"]
API --> EXTAGENT["外部 Agent / Reader Skill"]
BOT["微信机器人消息"] --> HUB["Agent Hub"]
HUB --> PARSE
HUB --> PROVIDER
```
## Remember → Understand → Monitor → Act
## Remember → 图片文字 → Understand → Monitor → Act
TraceMemo 的工作方式可以概括为:
```text
Remember → Understand → Monitor → Act
Remember → 图片文字 → Understand → Monitor → Act
```
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
- **Remember**:读取并解析本机微信数据,建立聊天档案、普通搜索和导出。
- **图片文字**:在本机识别图片里的文字,把截图、公告、报价图也变成可检索的内容。这一步不联网。
- **Understand**:Knowledge、AI Search / 问问微信、群聊日报。需要模型时,只把完成这次任务所需的受控上下文交给 Provider。
- **Monitor**:用成员快照对比发现群成员变化,产出成员退出事件。
- **Act**:自动化规则把前面的步骤串起来(定时日报、退群通知);动作经过统一执行边界,并留下执行记录。
回答和动作结果都应能回到来源或记录核对。
## 退群监控
@@ -46,7 +61,9 @@ Remember → Understand → Monitor → Act
Current Membership → Snapshot Diff → Member Event
```
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。监控关闭期间发生的变化,不会在重新开启后补报。
成员退出事件同时是「自动化」里「退群通知」规则的触发条件。
## 动作执行与审计
@@ -58,17 +75,20 @@ Feature → Policy → Gateway → Capability → Execution → Audit
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
这些动作统一由「自动化」管理,当前有三类规则:**@我生成日报**、**定时日报**、**退群通知**。发送目标支持当前群聊、文件传输助手、自己、指定好友,不是任意群发。
## 哪些步骤在本机
- 微信数据库读取与解析;
- 聊天档案浏览和普通搜索;
- Knowledge 索引与增量同步;
- 图片文字索引:识别图片中的文字完全在本机进行,原始图片不会因为本地识别而上传;
- 离线语音转写;
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
## 哪些步骤可能调用外部服务
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库,本机 OCR、离线语音转写和普通搜索也不会触发外发。
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
@@ -77,11 +97,13 @@ Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生
## 产品名词和用户任务的对应关系
| 用户想做什么 | 产品中可能看到的名称 |
| ------------------------ | ---------------------------- |
| ------------------------------ | ---------------------------- |
| 让 AI 找相关聊天 | AI Search、Retrieval |
| 让答案能回到原消息 | Evidence、Citation |
| 查看 AI 查找过程 | Search Trace |
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
| 搜到截图、公告图里写过的文字 | 图片文字索引、本机 OCR |
| 让日报、退群通知按规则自动执行 | 自动化、Policy、执行记录 |
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
| 让微信机器人调用本机能力 | Agent Hub |
@@ -0,0 +1,308 @@
# TraceMemo Local HTTP API / Agent API 能力审计
本报告基于当前代码、文档、IPC、Renderer 调用和相关测试做静态审计,对应当前发布应用版本 `2.5.0`;不连接真实微信数据库,也不修改生产代码、API Center 或测试。
主要代码入口:[http-server.ts](../../src/main/http-server.ts)、[automation-rule-store.ts](../../src/main/services/automation-rule-store.ts)、[group-exit-monitor-service.ts](../../src/main/services/group-exit-monitor-service.ts)、[group-stats-service.ts](../../src/main/services/group-stats-service.ts)、[api.md](../agent/api.md)。
## 1. Executive Summary
当前 HTTP 层声明了 **30 个 Method + Path 模板**:15 个 GET、12 个 POST、1 个 PATCH、1 个 DELETE、1 个 HEAD。共享 `LOCAL_API_ENDPOINTS` 定义 **14 个测试项**,但 Renderer 的 `API_ENDPOINTS` 实际只展示 **12 项**;个人微信能力和 `GET /scheduled-reports` 虽有共享定义,界面没有展示。界面也没有结构化 Query、媒体读取、定时日报写操作、退群监控、群统计、Automation 通用资源或执行日志查询。
当前不缺成熟的 Query primitive:结构化读取消息、Knowledge 搜索、消息前后文、会话概览和图片 OCR 搜索已经有 HTTP 契约。真正的能力缺口集中在 **监控配置、自动化规则通用管理、群员统计、执行历史、全局能力发现、发送状态和报告历史**。
几个影响后续设计的代码事实:
1. 定时日报已迁入 `AutomationRuleStore`。当前 `/scheduled-reports` 是旧 HTTP 契约的兼容投影,不是第二份活动规则存储;但它只表达“生成后发回来源群”,其他当前合法目标不会出现在该兼容 API 列表中。
2. 退群监控只负责监测范围、快照和事件历史;退群后如何通知由唯一的 `leave_notification` Automation 规则负责。二者应分别建资源。
3. 群统计 Service 足以提供活跃成员、当前沉默成员、时间范围、新鲜度和限制说明;它没有结构化的 former member 列表,也没有覆盖所有发言者的群总消息数。
4. 现有微信 Action Gateway 有策略、审计和幂等骨架,但策略目前主要校验收件人,以及 Automation purpose allowlist;手动用户 purpose 默认可放行。它只发个人微信,现有 `/agent/send` 则走 iLink 的 `WechatSendGateway`。两者不能直接视为统一的 Agent 安全边界。
5. HTTP 静态 GET handler 大多没有 method guard;对这些路径发 POST、PATCH 或 DELETE 仍会执行读处理。Automation store 的规则落盘失败会记录日志,但仍将内存中的规则返回为成功。
建议先做应用级能力发现、退群监控资源、严格校验后的 Automation CRUD/执行查询;发送和“立即执行”放到有 dry-run、明确确认、幂等键和 Action 审计的后续阶段。
## 2. Current API Inventory
以下按 `http-server.ts` 的路由分派和动态 route factory 盘点。静态读路由的 `Method` 是当前文档和产品语义的预期方法;实际接受方法的差异见本节末尾。
| Method | Path | 能力 | Read/Write/Execute | Service | API Center | 文档 | Agent 价值 |
|---|---|---|---|---|---|---|---|
| GET | `/api/v1/health` | HTTP 与数据库 ready 状态;唯一免 Token 路径 | Read | `isReady()` | 是 | 是 | 高:连通性 |
| GET | `/api/v1/current_time` | 本机时间、时区、日期 | Read | JavaScript `Date` | 是 | 是 | 中:相对日期换算 |
| GET | `/api/v1/contact` | 联系人和群列表;`filter`、`type`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:标识发现与 resolve 前置 |
| GET | `/api/v1/chatroom` | 群聊列表;`keyword`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:群标识发现 |
| GET | `/api/v1/recent_chat` | 最近会话;`limit` 默认 50 | Read | `chat-service.listRecentChat` | 是 | 是 | 高:导航/摘要 |
| GET | `/api/v1/chatlog` | 按 talker 和时间读取原始消息;移除 `contentData.aeskey` | Read | `chat-service.listMessages`、`resolveMd5` | 是 | 是 | 高,但旧式、未做结构化分页 |
| GET | `/api/v1/group_snapshot` | 群成员快照;必填 `md5` | Read | `chat-service.getGroupSnapshot` | 是 | 是 | 高:成员身份解析 |
| GET | `/api/v1/resolve` | 昵称、wxid、md5 解析为会话 | Read | `chat-service.resolveMd5` | 是 | 是 | 高;新资源宜返回稳定 ID 和歧义候选 |
| POST | `/api/v1/report` | 接收完整结构化日报并导出 HTML/PNG;当前拒绝外部 `templateRef` | Write:本地文件 | `group-report-service.exportGroupReport` | 是 | 是 | 低/中:低层渲染契约,Agent 须先拼完整结构 |
| POST | `/api/v1/agent/group-report` | 读群消息、调用 AI 生成群总结、导出 HTML/PNG | Execute:AI/本地文件 | `agent-group-report-service.generateAgentGroupReport` | 是 | 是 | 高但有模型费用/数据出站;结果不等同于报告历史记录 |
| GET | `/api/v1/agent/status` | Agent Hub、connector、数据 API、数据库状态 | Read | `agentHubService.getStatus` | 是 | 是 | 中:只覆盖 Agent Hub,不是应用总能力 |
| POST | `/api/v1/agent/send` | 通过 Agent Hub 连接器发送文字或媒体 | Execute:微信发送 | `agentHubService.testSend` → `WechatSendGateway`/iLink | 是 | 是 | 高但 R2;是测试入口,不含统一 Action policy/幂等确认 |
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信 text/image/voice 能力状态 | Read | `PersonalWechatCapabilityService`,由 `ScheduledReportApiService` 包装 | 否(共享定义有,界面未展示) | 是 | 高但只代表 personal,不代表 iLink |
| GET | `/api/v1/scheduled-reports` | 列出旧 DTO 可表达的定时日报规则 | Read | `ScheduledReportApiService.list` → 规则投影 | 否(共享定义有,界面未展示) | 是 | 高但不完整:仅 `source_chat` 目标 |
| POST | `/api/v1/scheduled-reports` | 创建旧型定时日报,来源群即发送目标 | Write:规则配置 | `ScheduledReportApiService.create` → `AutomationRuleStore` | 否 | 是 | 高;功能受旧 DTO 限制 |
| GET | `/api/v1/scheduled-reports/{id}` | 单条旧型日报规则投影 | Read | `ScheduledReportApiService.get` → `AutomationRuleStore` | 否 | 是 | 中/高:只支持兼容投影规则 |
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改旧型日报规则 | Write:规则配置 | `ScheduledReportApiService.update` → `AutomationRuleStore` | 否 | 是 | 高但只能改旧字段/目标 |
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除旧型日报规则 | Write:删除配置 | `ScheduledReportApiService.delete` → `AutomationRuleStore` | 否 | 是 | 中;新 API 应标 R3 并防护系统规则 |
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用规则 | Write:配置/未来执行 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高;启用后未来可能发送微信 |
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停规则 | Write:配置 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高 |
| POST | `/api/v1/scheduled-reports/{id}/run` | 手动触发完整日报规则 | Execute:可能调用 AI、保存历史、发微信 | `ScheduledReportService.runScheduledReportNow` → `AutomationService` | 否 | 是 | 高但 R2;当前无 HTTP 幂等键/确认 |
| GET | `/api/v1/scheduled-reports/{id}/executions` | 旧型 execution 历史和新 Automation execution 投影 | Read | `ScheduledReportService.listExecutions` | 否 | 是 | 高但旧响应模型/有限留存 |
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 旧文档称复用 PNG 重发 | Execute 路由存在但当前固定 `501 not_supported` | `ScheduledReportApiService.retrySend` | 否 | **路径有,语义已失效** | 无:不能重发 |
| GET | `/api/v1/media/{mediaId}` | 读取消息关联的图片二进制 | Read | `http-media-service.readImageMedia` | 否 | 是 | 高:图像证据 |
| HEAD | `/api/v1/media/{mediaId}` | 图片资源存在性/响应头 | Read | `http-media-service.readImageMedia` | 否 | 否 | 低/中 |
| GET | `/api/v1/query/capabilities` | Query Tool 支持的结构化操作、范围和上限 | Read | `LocalQueryApiService.capabilities` | 否 | 是(独立章节) | 高,但不是 TraceMemo 应用能力清单 |
| POST | `/api/v1/query/messages` | 单会话、范围、时间、方向、类型等确定性消息读取 | Read | `LocalQueryApiService.messages` | 否 | 是 | 高:推荐 Query primitive |
| POST | `/api/v1/query/search` | Knowledge 关键词检索,返回覆盖、新鲜度和 OCR 命中 | Read | `LocalQueryApiService.search` + `KnowledgeSearchService` | 否 | 是 | 高:必须读取 coverage/freshness |
| POST | `/api/v1/query/message-context` | 通过 opaque `messageRef` 读取前后文 | Read | `LocalQueryApiService.context` | 否 | 是 | 高:稳定消息引用 |
| POST | `/api/v1/query/conversation-overview` | 单会话范围的概览证据和 source coverage | Read | `LocalQueryApiService.overview` | 否 | 是 | 高:broad summary |
路由来源:[http-server.ts](../../src/main/http-server.ts#L212)、scheduled/query/media route factory(同文件 L404-L670)。总数是代码中声明的业务方法模板,不代表静态 handler 都正确拒绝其他动词:`/health`、`/current_time`、`/contact`、`/chatroom`、`/recent_chat`、`/chatlog`、`/group_snapshot`、`/resolve`、`/agent/status` 没有检查 `req.method`。这些路径携带错误动词仍会走同一 handler;特别是 `POST /health` 也绕过 Token 检查,因为鉴权例外按 pathname 判断。新/旧路由都应 fail closed 并对不支持的方法返回 405。
全局 `OPTIONS` 在路由和鉴权前处理;媒体额外支持 HEAD。非 health 路径要求 `Authorization: Bearer …`,但 `readBody` 没有大小上限。路由外层目前没有统一请求 schema、统一错误 envelope 或 request id。
## 3. Internal Capability Inventory
这里按产品能力追 Service → IPC/Renderer → HTTP → 外部 Agent,而不是按当前 API 名字扩展。
| 业务域 | 已有能力及实现 | IPC / Renderer | HTTP 现状 | Agent 结论 |
|---|---|---|---|---|
| Chat / Contact | 联系人、群聊、最近会话、解析、历史消息、群快照、消息周边上下文、媒体定位;`contact-resolution-service` 可精确匹配别名并返回歧义候选 | `db:getContacts`、`db:getGroupSnapshot`、消息查询/around 等,Chat/Contact/档案 UI | 旧 Reader routes + `/query/*` + 图片 `/media/{id}` | 已有 Read API 基础完整。新配置应使用 `m_nsUsrName` 对应的 wxid/roomId 等稳定 ID,不应把昵称当长期键 |
| Query / Knowledge | `messages`、`search`、`message-context`、`conversation-overview`;Knowledge 索引覆盖/新鲜度;Image OCR 可进入 Knowledge 搜索和证据 | `knowledge:search/getStatus/startIndex/cancelIndex`、AI Search UI、`LocalQueryToolExecutor` | 四个 Query 操作均已 HTTP 化;`query/capabilities` 只描述 Query Tool | 没有需要重做的基础 Query primitive。缺的是统一应用 capability/status、更多外层筛选和 API Center 展示 |
| Group Analytics | 活跃/沉默当前成员、每人 messageCount/lastMessageTime、窗口时间、memberCount、unattributed/system 消息、firstMessageTime、freshness/complete/limitations;索引未新鲜时最多等待 2 秒并如实降级 | `group-stats:getMemberStats`;聊天页群统计 UI | 无 | P1 候选。当前 query 要求群 `userMd5` + epoch 毫秒。former sender 只以限制文案给出数量,没有 formerMembers 数组/结构化计数;没有覆盖 former sender 的群总消息数字段,需扩 DTO 后再承诺 |
| Group Exit Monitor | enabled/running/nativeMonitor 状态、监控 roomId 集合、lastChecked、unread、事件历史;每次回传最多 500 条,但完整事件历史 append-only 长期保存;可立即 check、改范围、启停、筛事件、清历史、mark read | `group-exit-monitor:*`;`GroupExitMonitorWorkspace` | 无 | P0。拆成 monitor state/config、events、check。`checkNow` 可能发现事件并触发自动通知,不是纯读操作;重启监控会重建快照基线,暂停期成员变化不会补报 |
| Automation | 实际规则类型:`daily_report`、`scheduled_report`、`leave_notification`。`daily_report` 是现有消息触发条件/动作链,不是任意流程引擎;退群通知为固定 ID singleton。规则 CRUD、enable、执行记录读写均已有 Service/Store | `automation:*`;AutomationWorkspace 有规则、日志、定时执行、启停、删除和状态 UI | 没有通用 Automation API;仅 scheduled-report 兼容映射 | P0。使用 typed rule union;不要把内部任意 draft 原样开放。规则创建当前缺省 enabled=true,未知值会被归一化成默认;需 HTTP 严格校验,先 disabled + validate,再显式 enable |
| WeChat Send / Action | `WechatSendGateway` 有 personal/iLink transport resolution、text/image/voice/file 统一类型及 Send Log;`WechatActionGateway` 做 capability preflight、Automation purpose allowlist、Action audit、幂等和 Automation 3 秒间隔 | `wechat-personal:send`、`sendGeneratedTtsVoice`、`wechat-action-log:list`、Agent Hub connector 相关 IPC/UI | `/agent/send` 只走 Agent Hub/iLink 测试发送;个人 capability 有独立 GET;两类日志没有 HTTP | 分 transport 公布 capability;R2 send 通过经审计的业务门面,不直接暴露底层 gateway。现有 Action Gateway 还不是普适安全策略:`triggerType=user` 不按 purpose 限制;普通 `/agent/send` 没传 idempotency key,也没有 Action audit |
| Agent Hub | status、connector login/reconnect/disconnect、notification recipient/send、logs、conversation list/detail/clear、入站 inbox retry | `agent-hub:*`;Agent Hub UI | 仅 `/agent/status` 和 `/agent/send`;无 conversation/log HTTP | status 有只读价值。对话记录包含完整收发正文;inbox 包含 context token/raw items。Connector 生命周期、登录 QR/验证码、收件箱和通知 recipient 应保持 internal |
| Reports / Templates | 手动 report render/export;AI group report;本地 Report History list/save/update template/delete;内置/已安装模板和市场 catalog/install/uninstall | `report:*`、`report-template:*`、`report-template-market:*`;Reports 与 Template Market UI | `/report` 低层 export,`/agent/group-report` AI 生成;没有 history/template API | P1:只读 Report History 元数据/资产可分离设计。现有 `listGeneratedReports` 会读取每张 PNG 为 base64,并返回结构快照和本机绝对路径,不可原样直出。模板目录可读列入 P2;安装/卸载涉及网络与本地包写入,不宜第一批开放 |
| Recall Archive | 后台监听撤回变化,最多按会话存归档消息/撤回记录;chat-service 将 archive merge 到历史读结果 | 没有独立 CRUD IPC;设置开关和消息渲染 | 没有独立 archive API;旧 `/chatlog` 可能随底层消息返回 `recalled` 标记;Query DTO 未声明 recalled 字段 | 不开放原始 archive 管理。后续 Query 应明确返回 `recalled`/来源,避免把已撤回归档当普通消息证据 |
| OCR / Image Insight | System OCR 本地识别;image-text-index status/count/start/pause/resume/cancel/clear/repair;Image Insight 读/解密图片并可调用 AI Provider | `system-ocr:*`、`image-text-index:*`、`image:*` IPC;Search/Report UI | OCR 派生文本可通过 `/query/search` 得到;索引管理、单图 AI 分析无 HTTP | 已有搜索能力可用。状态可纳入 capability/status;索引删除、key/decoder 配置、任意图像 AI 分析涉及成本、私密图片和索引破坏,不列第一批 |
| 系统 / 数据 / 其他 | account discovery、DB key 管理、数据库 connect/root 重开、设置写入、cache summary/clear、app update、voice/TTS、export/import、Reader Skill 本地安装信息 | 多组 IPC;Settings、Cache、Export、Update、Voice UI | 无相应 HTTP API | 只读脱敏运行状态可按需求列 P2;DB key/root、通用 settings patch、cache 清理、任意文件路径、更新安装、TTS synthesis 等保持 internal |
### A. Chat / Contact 与 ID 语义
- `/contact`、`/chatroom` 改用 `listContactsAsync`,因为 macOS Session 可能只有原始 wxid/chatroom id,需要 hydrate 显示名;`ScheduledReportApiService` 却使用同步 `listContacts()` 解析群名。稳定 `talker` 可直接解析,但名称输入在需要 hydration 的运行时可能失败/退化。这是可复用 adapter 应统一异步解析的理由。
- ID 现在不是一个口径:旧 Query 的 `scope.conversationId`/`target` 解析为 `Contact.md5`;`group-stats` 传 `userMd5`;监控用 `roomId`(`xxx@chatroom`);新的 scheduled automation 用 `sourceConversationId`(wxid/roomId);老 HTTP 路由混用昵称、wxid、md5。保持已有 Reader 契约不动,新 API facade 应统一对外 canonical `conversationId`(底层当前联系人的稳定 username/wxid 或 roomId),并在 main adapter 转为服务所需 md5。名称只做 resolve,不持久化到规则。
- `chatlog` 时间边界是 Unix 秒,Query `absolute` 内部也是秒,而 group stats IPC 是 epoch 毫秒;新 Agent 资源建议用带时区 ISO-8601 输入/输出,并在 facade 单点转换。
- `/query/messages` 有 200 上限,`messageRef` 是 opaque 稳定引用;图片 OCR 文字和 Coverage 分开呈现。`/chatlog` 则支持旧 talker/time 风格但读取结果没有同等结构边界;作为兼容 Reader 保留,不作为新 Agent 配置/分析的默认接口。
### B. Group Exit Monitor 与 Leave Notification
`GroupExitMonitorService` 的真实 IPC 有 `getState`、`setEnabled`、`setGroups`、`checkNow`、`listEvents`、`clearEvents`、`markRead`。事件是群成员差异事实,包含 roomId、member wxid/name、previous/current count、detectedAt;monitor state 中 `events` 只是最近 500 条快照,`totalEventCount` 对应完整内存历史。
`AutomationService.handleGroupExit` 只处理 `BUILTIN_LEAVE_NOTIFICATION_RULE_ID` 对应的 singleton 规则。规则另有 `notifyScope` / `notifyRoomIds` 二次范围、target、template。Agent 配“监控 A/B/C”需改 monitor 范围;配置通知目标/通知哪些被监控群则另改这条 leave notification automation。两者不能合并为 `/monitors/{id}/notify`。
### C. Automation 与 Scheduled Report
`AutomationRuleStore` 的真实方法有 `listRules/getRule/createRule/updateRule/saveLeaveNotificationRule/deleteRule/setRuleEnabled`;`AutomationExecutionLogService` 提供 `list/record/clear/countSince`。执行日志最多留存 200 条,clear 属于破坏性操作。规则在 `{userData}/automation/rules.json` 中 JSON 持久化。
`scheduled-report-service.ts` 的调度来源是 `automationRuleStore.listRules()`,执行交给 `AutomationService.executeScheduledRule()`,execution 从 Automation Log 投影。迁移后的旧 `tasks.json`/`executions.json` 是只读历史存档。旧 HTTP API 通过 `ScheduledReportApiService` 转换旧 DTO;创建、修改、删除、启停最终也是读写 `AutomationRuleStore`。所以正确方案是保留兼容 facade,并建立 Automation canonical API,不要继续增加第二个 scheduled-report store。
旧 facade 的限制:只列/操作可投影为 `target.type === 'wechat_group'`、且目标等于来源群的规则。如今 scheduled automation 支持 source_chat/self/file_transfer/contact,故通过新 UI 创建为文件传输助手或联系人目标的规则,会从旧 `/scheduled-reports` 列表隐藏。旧 API 输入 schema 也不能表示完整 scheduled config(成员名、消息类型、模板、timeout、postfix 等)。
写 API 前还需处理 `AutomationRuleStore` 的归一化和持久化契约:未知 ruleType 会降成 `daily_report`,大部分错误枚举会静默落安全默认;缺省 enabled 是 true;`persist()` catch 写盘错误后只记 warning,Store 仍返回创建/更新后的对象。HTTP adapter 必须先 strict validate,且 Store 需要可观察的持久化结果,不能把内存态冒充成功。
### D. Group Analytics 确认项
`GroupStatsService.getMemberStats` 已有可直接复用的核心计算;接口具体有:
- 当前群成员:`memberCount`、`activeMembers`、`silentMembers`、各活跃成员 `messageCount`/`lastMessageTime`;
- 查询窗口:`startTime`、`endTime`(epoch ms)、`firstMessageTime`;
- 数据完整性:`freshness = fresh|stale|unknown`、`complete`、`limitations`;
- 诊断:`unattributedMessages`、`excludedSystemMessages`。
成员名单是当前成员集合;知识库统计的 sender 不在当前集合时被排除,只在 `limitations` 中增加“另有 N 位窗口内发言者已不在当前群成员名单”。Service 不返回其身份/每人消息数,也没有 `totalMessageCount`。若 Agent 需要“前成员榜”或全群消息数,需要先扩展 Service/shared type;不能由 API adapter 从 limitation 文案反解析。
## 4. API / Docs / API Center Drift
| 项目 | 代码事实 | 漂移/影响 |
|---|---|---|
| HTTP、共享定义与界面列表 | HTTP 有 30 个 method/path 模板;`LOCAL_API_ENDPOINTS` 定义 14 项,Renderer `API_ENDPOINTS` 实际展示 12 项 | 16 个 HTTP 操作模板没有共享定义;另有 2 个已定义项(个人微信能力、定时日报列表)没有展示。界面仅呈现 12/30 项,不能作为完整 API catalog |
| Scheduled Report 展示 | 共享定义只有 `GET /scheduled-reports`;该项本身也未进入 Renderer 列表 | POST 和 task action 不显示,GET 列表也不显示;Agent 在 API Center 里无法发现这组 API |
| WeChat Capability 展示 | 共享定义有 `GET /wechat-personal/send-capability`;Renderer 列表未包含它 | API Center 看不到个人微信发送能力状态,用户可能误把 Agent Hub 状态当成完整发送能力 |
| Query 展示 | Query 文档在 `api.md` 的独立 LLM-friendly 章节,Service/HTTP 实现完整 | API Center 看不到;用户可能误认为 Reader API 仍只有旧 chatlog |
| Media 方法 | `/media/{mediaId}` 支持 GET、HEAD | 文档仅列 GET;API Center 都未列 |
| Retry Send | 文档表称 retry-send“复用已有 PNG 重试发送” | `ScheduledReportApiService.retrySend()` 当前无条件抛 `501 not_supported`;integration/unit tests 也未覆盖 retry 路由的这项现状 |
| Scheduled Report 完整性 | 旧 facade 只 project `source_chat` | UI 可保存的其他 scheduled target 会从旧 API list/get 隐藏;不是两份存储,但旧 API 不是 Automation API 的完整别名 |
| Health 版本 | `/health` 固定返回 `version: "1.0.0"` | 与当前 package version `2.5.0` 不同,Agent 无法据此判断应用版本 |
| HTTP 动词 | 九个静态 GET 语义路由无 method guard | POST/PATCH/DELETE 等也可能调用读取逻辑;`/health` 任意 method 均免 Token。测试目前未锁定统一 405 契约 |
| 请求/错误 schema | JSON parsing 和错误形状分散:通用 `sendError`、ScheduledReport 专用 error、Query status body、业务自身 result | Agent 要写多套解析逻辑;共享 API schema 和统一错误 code 不存在 |
| 命名 | `/contact`、`/chatroom`、`/recent_chat`、`/group_snapshot` 与 `/scheduled-reports`、`/query/*`、`/agent/*`、`/wechat-personal/*` 并存 | snake_case 旧路径、资源路径和“Agent 为业务 owner”的命名混杂;新接口不能继续沿用此漂移 |
文档 [api.md](../agent/api.md#L35) 基本列出当前 HTTP 路径,Query 在后续单独说明;除 HEAD 外没有发现漏写的当前业务路径,但 retry-send 的成功语义过期。API Center 的来源是单独的 [local-api-test.ts](../../src/shared/local-api-test.ts) 和 [apiEndpoints.ts](../../src/renderer/src/features/api-center/model/apiEndpoints.ts),没有从 HTTP route/schema 派生。测试现有 `local-api-auth` 覆盖鉴权、媒体、部分 Query 和 Agent send;`scheduled-report-api` 覆盖旧生命周期;`local-api-contact-search` 覆盖 hydrate。它们没有自动比对 HTTP route、文档、Catalog 三者,也没有覆盖全部 method guard 和 retry-send。
## 5. Candidate API Matrix
风险按本任务口径:R0 只读;R1 本地配置/应用状态修改;R2 微信发送、AI/provider 调用等外部副作用;R3 删除或清空不可轻易恢复的数据。R1 不代表没有后续行为:enable 一条定时规则会武装未来的 R2 执行。
| Capability | 当前实现 | 当前 API | 建议 | Agent 用例 | Risk | Priority |
|---|---|---|---|---|---|---|
| 联系人/群/会话 resolve | Chat Service + Contact Resolution | 有旧 routes;Query 内 resolve | 保留旧路由;新 resource 返回稳定 ID、歧义候选 | 查找群并取得 roomId | R0 | P0(复用) |
| 结构化消息/搜索/上下文/概览 | LocalQueryApiService + Knowledge | `/query/*` | 保持契约;加 route schema/catalog,后续可升级稳定 ID | 查聊天、关键词/OCR、补上下文 | R0 | P0(复用) |
| 应用 capability discovery | 各 Service 能回答局部状态 | 无;`query/capabilities` 仅 Query Tools | 新 `GET /capabilities`,区分 supported/available/reason/operations | 发现自动化、监控、统计、发送 transport | R0 | P0 |
| Group Exit Monitor 状态/范围 | GroupExitMonitorService | 仅 IPC | GET state + PATCH enabled/roomIds | 查看监控、监控/停止一个群 | R0/R1 | P0 |
| Group Exit events | Monitor JSONL + listEvents | 仅 IPC | GET 带 stable roomId/time/cursor/limit | 最近 7 天谁退群 | R0 | P0 |
| 手动检查退群 | checkNow 会扫描并触发事件 handler | 仅 IPC | 有外部通知时按 R2 操作开放,先 validate effects + confirm | 立即检查一次 | R2 | P1 |
| Automation 规则 CRUD | AutomationRuleStore | 通用 IPC;HTTP 仅旧 scheduled facade | typed union CRUD;create disabled;validate 再 enable;保护 singleton/system rules | 创建、列出、修改、暂停自动化 | R1/R3(delete) | P0 |
| Automation validation/dry-run | 现有编辑器 preview 分散;无通用 validator API | 无 | `POST /automations/validate`;不落盘、不发送 | 确认群、目标、模板、下次运行和能力 | R0 | P0 |
| Automation execution history | AutomationExecutionLogService,最多 200 条 | schedule 专属旧投影 | 规范化 Automation execution 读接口;清日志不开放第一批 | 查看失败、按 rule 过滤 | R0/R3(clear) | P0 |
| Group member stats | GroupStatsService | 仅 IPC | 按稳定 group ID + ISO window 读统计;先补 former/total 语义 | 近 30 天活跃榜 | R0 | P1 |
| WeChat capability | personal capability service;Agent Hub status | personal GET + Agent status | 新全局 capability 含 personal/iLink 和内容能力;旧路由保留 | 检查发送当前是否可用 | R0 | P0 |
| 手动微信发送 | WechatSendGateway + Action Gateway | `/agent/send` iLink test send | 新 send command 经受限 Action facade,强制 stable recipient、confirm、idempotency | 文件助手测试消息 | R2 | P1 |
| Send Log / Action audit | Send Log 500 条;Action audit 500 条;IPC action-log | 无 HTTP | 分层只读分页,preview 脱敏;按 executionId/requestId 关联 | 查最近发送失败、审计规则动作 | R0 | P1 |
| Agent Hub status | AgentHubService.getStatus | `/agent/status` + IPC | 保留 alias,新资源名归 `/agent-hub/status`,与 app capabilities 分开 | 查 Hub/connector online | R0 | P1(复用) |
| Agent Hub 对话内容 | Conversation Store,最多 50 会话×500 条 | 仅 IPC | 默认为 Internal;若产品确认需要,另做显式 opt-in、分页/时间过滤 | 查看机器人与某人的对话 | R0(高隐私) | 不建议第一批 |
| AI 群日报 | AgentGroupReportService + export | `/agent/group-report` | 保留兼容;未来先 validate model/range/group/data egress,再异步 job | 生成临时总结图片 | R2(provider/本地文件) | P1 |
| 日报历史 | ReportHistory Service,IPC CRUD | 无 | 分页 metadata DTO;图片 asset 单独下载;不返回 base64/路径/完整 snapshot | 昨天生成过哪些日报 | R0 | P1 |
| 模板列表 | Template Service + market catalog | 仅 IPC | 仅已安装模板只读列表列 P2 | 有哪些日报模板 | R0 | P2 |
| 模板安装/删除/历史改版 | Template Service/Market + Report History | 仅 IPC | 不开放通用 HTML/路径写入;将来单独授权且保留校验 | 安装或修改模板 | R1/R3 | Maybe/P2 |
| Recall archive 查询 | 内部 archive merge 到历史消息 | 无独立 API | 不单独开放磁盘 Archive;给 Query 增 `recalled` 来源标记 | 找被撤回消息 | R0(敏感/语义风险) | P2 |
| Image OCR index 操作 | image-text-index service | IPC(含 clear/repair) | coverage 状态可汇入 capabilities/status;不让 Agent 操作 clear/reset | 查 OCR 覆盖 | R0/R1/R3 | P2 |
| AI image insight | ImageInsightService 读/解密图片并调 vision provider | IPC | 不暴露任意 hash/message AI 分析,除非有成本/隐私授权 | 理解群图片 | R2 | 不建议第一批 |
| DB key、根目录、settings、cache | 多个设置/DB/cache Service | IPC/UI | 禁止通用 settings patch / 文件路径 / DB key API;只加白名单状态字段 | 修改本机数据库、安全设置 | R1/R3 | 不建议开放 |
| Connector 生命周期/inbox | AgentHubService + WechatInboundInbox | 仅 IPC/内部 | connector 登录、验证码、QR、inbox、context token 不对 Agent 暴露 | 重连或直接拿入站 token | R1/R2 | 不建议开放 |
## 6. P0 Recommendation
第一批目标是“让 Agent 能配置和核验 TraceMemo,但不意外发消息”。建议只包括:
1. `GET /api/v1/capabilities`:应用级 capability,不与现有 `/query/capabilities` 合并。返回版本、DB/readiness、supported vs available、不可用原因和依赖;发送分 personal/iLink 与 text/image/voice 能力。
2. Group Exit Monitor:读取状态、显式配置 monitored roomIds、读取历史事件。`PATCH` 只接 canonical 群 ID,拒绝不存在/非群 ID;修改范围响应明确显示后台 baseline/check 状态。`check` 先列 P1,因为它可能启动退群通知发送。
3. Automation typed CRUD:列/读规则、创建 disabled 规则、更新、显式启停、执行历史读取。Leave Notification 仍使用固定 singleton id,不允许创建重复规则;拒绝未知字段/未知 enum,而不是靠 `normalizeRuleDraft` 静默修正。
4. `POST /automations/validate`:验证目标群、稳定通知 recipient、模板变量、report/template 配置、send capability 和 nextRunAt;只返回 plan,不落盘、不发送。
5. 运行状态与错误契约:一致的 405、最大 body、请求 ID、错误 envelope;这是任何新 Agent 写接口前的 foundation,不是大规模权限系统。
Agent 实现示例(概念流程):
- “监控 A/B/C 退群”:resolve 三个群为 roomId → validate scope → PATCH monitor group IDs。
- “A 群有人退出就通知文件助手”:读取 monitor scope 和 singleton leave rule → validate 类型/notify scope/target → 更新规则但保持 disabled → 用户/Agent 明确 enable。监控与 leave-notification 是两份正交配置。
- “每天 20:00 生成产品群日报”:resolve sourceConversationId → validate scheduled rule(包含 target/transport/模板/时区/next run)→ 创建 disabled → 显式 enable。旧 `/scheduled-reports` 无法表达所有当前 config,不承担新 Agent CRUD。
- “昨天哪些自动化失败”:读 automation executions,按本机 timezone/UTC offset 和 status 查询,不清理日志。
## 7. Proposed Resource Model
采用业务 capability 资源,HTTP server 只负责 transport、auth、body、统一错误;每个 domain route 调用独立的 main-process API facade/Service adapter。Facade 复用当前 Service/Store,不把 UI IPC 当 HTTP RPC 转发层。
| Resource | 职责 | 现有路径的处理 |
|---|---|---|
| `system` | health、版本、应用级 capabilities、运行状态 | `/health` 保留;新增 `/capabilities`;`query/capabilities` 不改语义 |
| `contacts` / `groups` | 稳定 ID 列表、resolve、群成员快照/统计 | `/contact`、`/chatroom`、`/resolve`、`/group_snapshot` 保留兼容 |
| `query` | 消息/搜索/上下文/概览证据 | 现有 `/query/*` 保持;query ID 口径升级需兼容 reader skill |
| `monitors` | 退群监控范围、启停、事件、显式 check | 新 `/monitors/group-exits`,与通知规则分离 |
| `automations` | 规则 typed CRUD、validate、启停和运行 | 新 `/automations` 是 canonical HTTP resource;底层仍由 `AutomationRuleStore` 存储 |
| `executions` | 跨 Automation/Action/Send 的只读运行视图 | 新 `/executions` read model,不合并各自写存储 |
| `wechat` | transport capabilities、受控 send command、Send Log/Action audit | `/agent/send` 与 `/wechat-personal/send-capability` 保留兼容 |
| `reports` | report history 元数据/asset;installed templates read-only | `/report` 与 `/agent/group-report` 保留为不同兼容操作 |
| `agent-hub` | Hub/connector 状态;conversation API 默认 internal | `/agent/status` 保留 alias,不复用 app capability |
| `developer` | 高级 raw request tester 与诊断 | API Center 的 tester;不作为 Agent 业务 API |
共享资源原则:新 API 输入以 stable id 为主、名字只用于 resolve;所有写请求 strict validate;时间对新资源用 offset ISO-8601;分页使用 `limit` + `cursor`;成功/失败使用一个 typed envelope;不直接返回 app userData path、token、context token、AES key 或原始 transport payload。
## 8. Safety Model
### 当前边界
- 默认监听 `127.0.0.1:6131`,但 host/port 由设置和 `api:start` 调用传入,API Center 会警告非 loopback。CORS 只允许 loopback Origin,但不带 Origin 的 curl/Agent 请求仍可用 Token;CORS 不是本地进程授权边界。
- `/health` 公开,其余 endpoint 共用单一 Bearer Token。Token 为 32 random bytes、safeStorage 加密存储并设 `0600`,没有 read/config/send scope。拿到 token 即可读取聊天,也能建/删/启停规则、立即发送。
- HTTP `/agent/send` 的消息进入 `WechatSendGateway`,因此有低层 Send Log;但没有 `WechatActionGateway` 的业务 Action audit/策略决策,也没有调用方 Idempotency-Key。Personal capability 路由只报告个人微信状态。
- `WechatActionGateway` 的请求包含 `purpose`、`triggerType`、recipient、content、`idempotencyKey`、`executionId`;Automation trigger 有 purpose allowlist,sender capability 会先检查,审计记录会保存 content preview/hash。当前 `evaluateWechatActionPolicy` 对 `triggerType=user` 不做 purpose allowlist,`shouldUseAiPolicy` 固定 false;幂等只对显式 key 或历史特定 scheduled request 生效。它目前只发个人微信,不能直接替换 iLink send。
- Action Audit 和 Send Log 各自上限 500;Automation Execution Log 上限 200。三类日志粒度不同,不是重复的同一事实。
### 分级建议
| 风险 | 操作 | 建议控制 |
|---|---|---|
| R0 | 查询聊天/成员/事件/状态/统计/日志/报告元数据 | 保留本地 Token;响应明确 scope、coverage、freshness、隐私字段 |
| R1 | 修改监控群、Automation 配置、启停、模板设置 | typed validation、dry-run plan、显示持久化成功;enable 需确认它武装未来发送 |
| R2 | 微信发送、立即执行日报/退群通知 check、调用 AI Provider 生成报告 | 明确 recipient 和内容/规则;`Idempotency-Key` 必填;统一 Action policy + Action audit + Send Log;重复请求回放原结果;返回发送状态 |
| R3 | 清理退群事件、删除 report、清空执行/发送/Action 日志、清 cache/index、删规则 | 初始不开放;如以后开放,独立权限、预览计数、可恢复备份/本地确认,不接受批量 wildcard |
现有 Bearer Token 不足以支撑“查询、配置、发送、清理”都对一个不受信 Agent 开放。近期不必造完整账户系统,但应先修路由 method/body/validation,提供默认只读或 disabled 配置工作流;后续可加入多个 named token + scope(`read`, `configure`, `send`, `destructive`),R2 请求确认和 durable idempotency。风险分类也需承认本地 artifact write(如 `/report` 导出)不是配置本身,可先按 R1 local-write 处理。
## 9. Compatibility Plan
1. 不 rename/remove 任何现有 route。Reader Skill 依赖旧 contacts/chatlog/media 路径;新 structured query 已经是更合适的 Agent query,但两者并行。
2. 新 `/automations` 读写同一 `AutomationRuleStore`。旧 `/scheduled-reports*` 改为明确标注 Deprecated 的 compatibility adapter;维持现有 request/response shape 和 source_chat 子集,不维护第二个任务存储。响应可加 `Deprecation` header/文档说明,未定 sunset 前不返回 breaking error。
3. `/scheduled-reports` 的投影不能假装覆盖所有 scheduled automation。旧 list/get 只反映 source_chat;新 clients 必须迁到 `/automations?type=scheduled_report`。旧 `retry-send` 保留返回 501,文档明确废弃;不能伪造已发送成功。
4. `/agent/send` 继续表示现有 iLink/Agent Hub 测试发送。新 `/wechat/send` 必须先明确 transport/recipient schema,再通过能统一 personal+iLink 的受控 Action facade;如果不能保留旧发送语义,就将旧路径作为 adapter 而不是简单 alias。
5. `/wechat-personal/send-capability` 保持 personal-only 兼容 view;新全局 capability 返回 transport map。应用能力 `/capabilities` 和 Query Tool 的 `/query/capabilities` 各自有清晰不同的契约。
6. 同一 shared contract/catalog 应供 HTTP 验证、API Center、文档和 route contract tests 使用;把实际路由、文档和 API Center 三者 drift 变成测试失败,而不是发布后人工发现。
## 10. Proposed Phase Plan
### Phase A — HTTP Contract / Facade
给现有 routes 加明确 method guard、body size limit、严格 shared request schema、统一错误 envelope/request id;补 `AutomationRuleStore` 写盘成功/失败结果;建立稳定 conversation ID adapter 和 route contract tests。保留 raw Node HTTP,不需要为第一阶段换 web framework。
### Phase B — Read + Validation P0
增加应用 `/capabilities`、monitor state/events、automation rules/executions 读取、`/automations/validate`、group stats adapter。monitor `check` 因可能触发 notification 暂留 P1 或先加 side-effect confirmation。把新资源路径、schema、风险元数据接进 EndpointCatalog/生成文档。
### Phase C — Automation Configuration
开放 disabled create、PATCH、启停和 DELETE 防护;退群通知专用 singleton upsert 接入同一规则资源;scheduled report 使用真实完整 config;旧 scheduled API 只做 compatibility projection。先做 dry-run 再允许 enable。
### Phase D — Side Effects / Execution Read Model
扩展一层统一 `Action` facade 支持 iLink + personal transports,并有 per-purpose policy、recipient allowlist/validation、确认语义、强幂等、Action audit 到 Send Log correlation。再开放 send/check/run;按需增加 `/executions` read projection,不合并底层日志存储。
### Phase E — API Center
Overview、Query、Monitors、Automations、WeChat Actions、Reports、Developer 分区;按业务任务做 schema-aware 表单/效果预览,Raw Request Tester 留在 Developer。展示 Token scope/host 范围/transport capability,而不只是固定 URL 测试器。
## 11. Concrete Endpoint Proposal
下表是下一阶段建议契约,不表示当前已实现。新写 API 应统一错误:`{"error":{"code":"...","message":"...","details":{...}},"requestId":"..."}`。时间使用带 offset 的 ISO-8601;接口只接受 stable IDs。
| Method | Path | Request → Response | Risk | Underlying Service |
|---|---|---|---|---|
| GET | `/api/v1/capabilities` | 无 → app version/readiness + `query`,`groups.memberStats`,`groupExitMonitor`,`automations`、每种 `wechat.transport/content` 的 `supported/available/reason` | R0 | 新薄 facade 汇总 LocalQuery、GroupStats、Monitor、AutomationStore、personal capability、AgentHub status |
| GET | `/api/v1/monitors/group-exits` | 无 → `{enabled,running,monitoredConversationIds,lastCheckedAt,eventCount}` | R0 | `GroupExitMonitorService.getState` |
| PATCH | `/api/v1/monitors/group-exits` | `{enabled?,monitoredConversationIds?}` → 保存后的 state;监控 ID 必须 resolve 到现有群 | R1 | `setEnabled` / `setMonitoredRoomIds` |
| GET | `/api/v1/monitors/group-exits/events?conversationId=&since=&until=&limit=&cursor=` | ISO 时间和稳定群 ID → `{events,nextCursor}`,事件显式 `eventId`、group/member、counts、detectedAt | R0 | `GroupExitMonitorService.listEvents`;为无 cursor 的现有 list 加稳定分页 adapter |
| POST | `/api/v1/monitors/group-exits/check` | `{confirmSideEffects:true}` + `Idempotency-Key` → checkedAt、新事件数、notification execution refs | R2 | `checkNow`;因检查可发现事件并调用 leave-notification Automation,不应标成纯读 |
| GET | `/api/v1/automations?type=&enabled=` | 无 → typed rules page;包括 singleton leave rule | R0 | `AutomationRuleStore.listRules` |
| POST | `/api/v1/automations` | typed `AutomationRuleDraft`,create 默认 `enabled:false` → `{rule}` | R1 | `AutomationRuleStore.createRule`(需先强化 strict validation/persist result) |
| GET | `/api/v1/automations/{ruleId}` | 无 → `{rule}` | R0 | `AutomationRuleStore.getRule` |
| PATCH | `/api/v1/automations/{ruleId}` | typed partial config → `{rule}`;`ruleType` 不可变 | R1 | `AutomationRuleStore.updateRule` |
| DELETE | `/api/v1/automations/{ruleId}` | 无 → `{deletedId}`;默认拒绝 builtin/system singleton 删除 | R3 | `AutomationRuleStore.deleteRule` + protected-id policy |
| POST | `/api/v1/automations/validate` | typed draft → `{valid,normalizedDraft,issues,effects,resolvedTargets,nextRunAt,capabilities,validationId}`;不保存、不发送 | R0 | 新 validator adapter:contact resolve、template validator、capability services、schedule pure functions |
| POST | `/api/v1/automations/{ruleId}/enable` | `{validationId,confirmFutureEffects:true}` → `{rule}`;validation hash 必须匹配当前规则 | R1(武装未来 R2) | `AutomationRuleStore.setRuleEnabled` + validator |
| POST | `/api/v1/automations/{ruleId}/disable` | 无 → `{rule}` | R1 | `AutomationRuleStore.setRuleEnabled` |
| POST | `/api/v1/automations/{ruleId}/run` | `{confirmSideEffects:true}` + `Idempotency-Key` → `{execution}` | R2 | `AutomationService.executeScheduledRule`;只对具有 `run` 语义的 rule type 开放 |
| GET | `/api/v1/automations/executions?ruleId=&status=&trigger=&since=&until=&limit=&cursor=` | 无 → `{executions,nextCursor}` | R0 | `AutomationExecutionLogService.list`;需加 timestamp/filter/cursor adapter |
| GET | `/api/v1/groups/{conversationId}/member-stats?start=&end=` | ISO 时间窗口 → active/silent/counts/freshness/complete/limitations;未来要 former members 则先扩 shared result | R0 | `GroupStatsService.getMemberStats`;adapter 把 canonical roomId 转 md5 |
| GET | `/api/v1/wechat/capabilities` | 无 → personal/iLink 分 transport 状态与内容能力 | R0 | `PersonalWechatCapabilityService` + `WechatSendGateway.hasIlinkSender`/Agent Hub connector health |
| POST | `/api/v1/wechat/send` | `{recipient:{type,id},content:{type:"text",text},confirm:true}` + 必填 `Idempotency-Key` → `{actionId,status,transport,sendLogRef}` | R2 | 扩展后的 `WechatActionGateway`/统一 Action facade;不得裸调 `WechatSendGateway.send` |
| GET | `/api/v1/wechat/send-logs?status=&since=&limit=&cursor=` | 无 → 只读、脱敏 Send Log page | R0 | `WechatSendGateway.listSendLog` / `WechatSendLogService.list` |
| GET | `/api/v1/wechat/action-logs?executionId=&status=&since=&limit=&cursor=` | 无 → 业务 Action 审计 page;preview 默认截短/可省略 | R0 | `WechatActionLogService.list` / `WechatActionGateway.listAuditRecords` |
| GET | `/api/v1/executions?source=&status=&since=&until=&limit=&cursor=` | 无 → 统一只读 projection,每项保留 source/executionId/trigger/action/status/error/times/correlation IDs | R0 | 组合 `AutomationExecutionLogService`、Action audit、Send Log;不合并其存储 |
| GET | `/api/v1/reports/history?groupId=&since=&until=&limit=&cursor=` | 无 → 仅元数据分页,不带 `generatedImage`、本地绝对路径或完整 report snapshot | R0 | `listGeneratedReports` 经 summary adapter,最好先让 Service 支持 metadata-only |
| GET | `/api/v1/reports/history/{reportId}/image` | 无 → PNG binary | R0 | Report History 的 file resolver;仅按内部 report id 解析,不接收任意路径 |
| GET | `/api/v1/report-templates` | 无 → 已安装模板的 stable id/name/version/available 列表 | R0 | `reportTemplateService.list` |
| GET | `/api/v1/agent-hub/status` | 无 → hub/connector/dataApi/dbReady 的脱敏状态 | R0 | `AgentHubService.getStatus`;`/api/v1/agent/status` 保留 alias |
`POST /wechat/send` 建议先只开放 text + 明确目标;image path/url、voice 和 file 类型各自扩大本机文件/外传风险,需独立 validation/permission,不从现有任意 `msg` 输入自动继承。
## 12. Do Not Expose
- 任意数据库 key、图片 AES key、微信登录凭据、iLink `context_token`/bot token、inbound inbox raw items;这些是密钥或未处理消息,不是业务 API。
- 通用 `settings:set`、任意 `dbRoot`、数据库 disconnect/reopen、cache 全清、Knowledge/OCR 索引 clear/reset;配置或 destructive blast radius 远高于 Agent automation 管理需求。
- Agent Hub QR/login/verify-code/reconnect/disconnect/connector lifecycle。它会影响进程状态/账户登录,也会暴露登录材料。
- `WechatInboundInbox` pending/clear/complete/recordFailure 等队列控制。它是 at-least-once 消息交付内部机制,外部 ack 会破坏不丢消息语义。
- Agent Hub 完整对话正文默认不暴露。若明确产品需要,独立设计用户授权、最小时间窗口、分页、清理和脱敏;现有 Conversation Store 为本机完整收发留档。
- 任意本地 file path、path traversal 类导出/报告资产操作;不要把 IPC 的 file chooser、reveal、delete path 形状直接变 HTTP 参数。
- 单图 AI insight/任意 Vision analyze、批量 TTS synthesis、AI Provider 配置/测试和 app update download/install;它们具有费用、敏感图片/文本上传或应用安装影响。
- 清除退群、Automation、发送/Action、报告历史或批量发送接口作为 P0。清理类不是“管理配置”的必要前提,应使用 R3 独立权限及本地可恢复流程。
- 通用“任意 Automation DAG/任意脚本/任意 purpose”的创建。当前实现只有明确的三类规则和固定动作,不是 workflow engine;扩展功能应有显式 ruleType/schema,而不是暴露内存对象。
## 13. Open Questions
以下属于产品边界选择,无法只从代码决定:
1. 新 Agent API 是否允许同时操作个人微信和 Agent Hub/iLink,还是第一版限定一个 transport?现有 capability 与 send endpoint 分属两套连接状态。
2. Agent 是否默认只拿只读 scope;配置和 R2 send 是否要求独立 token/用户确认?当前只有单一 Bearer Token。
3. 多账号是否属于本阶段?当前 Query/GroupExit 绑定当前活动数据库账号,Agent Hub 有自己的 connector accountId;没有统一 account-scoped API model。
4. Agent Hub 完整对话是否要作为 API capability?它含完整消息正文,和从微信数据库按 query 搜历史是不同隐私边界。
真实微信 runtime 可用性、平台 hydration 和 transport 能力需在后续实现集成测试中验证;静态代码审计无法替代真实数据库/微信连接的运行时验证。
+14 -4
View File
@@ -34,24 +34,34 @@ pnpm test:e2e:build
## 代码变更对应文档
| 代码区域 | 需要同步检查的文档 |
| --------------------------------------------------------- | ------------------------------------------------------- |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
| `src/main/services/recall-archive-service.ts` | `user-guide/privacy.md` |
| `src/main/services/system-ocr-service.ts`、`image-text-index-service.ts` | `user-guide/knowledge.md`、`concepts/how-it-works.md`、`user-guide/privacy.md` |
| `src/main/services/image-insight-service.ts`、AI Provider | `user-guide/report.md`、`user-guide/privacy.md` |
| `src/shared/automation.ts`、自动化服务与执行网关 | `user-guide/report.md`、`concepts/how-it-works.md`、`docs/README.md` |
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
两条容易被写错的边界:
- **防撤回已下线**(`src/main/services/recall-archive-service.ts` 保留但不再启动):设置入口隐藏,`recallProtectionEnabled` 在所有读写路径上被强制收敛为 `false`。不要把它写回用户指南。
- **图片文字索引(本机 OCR)与图片理解(需要 Provider)是两条不同的路径**:前者写入本地索引、能被搜索,且不联网;后者只在日报和设置里的模型检测中使用。改其中一条时不要把另一条的隐私口径带过去。
## 文档检查
提交文档变更前至少执行:
```bash
git diff --check
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
# 过时版本号、旧品牌名、旧结构叙述、MCP 误解
rg -n "v2\.1\.7|2\.4\.0|v2\.2\.0 兼容期|无鉴权|mcpServers" README.md docs --glob '*.md' --glob '!development/overview.md'
# 不存在的产品结构(定时日报已并入自动化)
rg -n "日报 → 定时日报|Monitor / Automation" README.md docs --glob '*.md'
```
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。发版前额外确认 `README.md` 里的版本号与 `package.json` 的 `version` 一致。
+27 -5
View File
@@ -1,6 +1,6 @@
---
name: tracememo-reader
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体,并管理定时日报任务。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结、查询或修改定时日报时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
---
# TraceMemo Reader
@@ -28,7 +28,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
## 端点速查
| 方法 | 路径 | 用途 |
| ------ | ----------------------------------- | ------------------------------------------------- |
| ------ | ------------------------------------------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
@@ -38,7 +38,12 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
| POST | `/query/messages` | 按目标与时间范围取消息(结构化,不调用 AI) |
| POST | `/query/search` | 受限语义关键词检索(依赖本地索引,见 freshness) |
| POST | `/query/message-context` | 用 `messageRef` 取某条消息的前后文 |
| POST | `/query/conversation-overview` | 按会话与时间范围提取可总结的证据 |
| GET | `/query/capabilities` | Query 端点能力目录 |
| GET | `/wechat-personal/send-capability` | 个人微信发送能力状态(文字 / 图片 / 语音) |
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
| POST | `/scheduled-reports` | 创建定时日报任务 |
@@ -48,10 +53,13 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
| POST | `/scheduled-reports/executions/:executionId/retry-send` | 复用已有 PNG 重试发送 |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
| POST | `/agent/send` | 已连接机器人发送测试 |
| POST | `/agent/send` | 已连接机器人发送测试(文字或本地图片) |
这个 API **不只是只读的**:`/report` 会渲染并写文件,`/agent/send` 会真的发出微信消息,`/scheduled-reports*` 会创建、修改、删除或立刻执行定时任务。这些调用都要先确认用户意图;`DELETE` 与 `/agent/send` 尤其需要用户明确确认。
## 定时日报管理
@@ -78,7 +86,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
}
```
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态不是 `ready` 时(`unsupported`、`unconfigured`、`needs_binding`、`initializing` 或 `error`),直接说明需要先在 TraceMemo 的“设置 → 发送能力”里完成个人微信绑定和能力检测,不要继续创建任务。
### 查看、修改和执行
@@ -102,6 +110,20 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- 根据多条消息整理出的总结;
- 没有来源支持的推断。
## 结构化查询(query/\*)
需要按目标 + 时间范围稳定取数时,优先使用 `query/*`,而不是自己拼 `chatlog`:
- `query/messages`:按 `target`、`timeRange`、`direction`、`messageTypes` 取消息;
- `query/search`:受限语义关键词检索,依赖本地索引;
- `query/message-context`:用返回的 `messageRef` 取前后文;
- `query/conversation-overview`:按会话与时间范围提取可总结的证据。
两个要点:
- `messageRef` 是服务端生成的不透明引用,**不要**自行构造 wxid、md5 或数据库路径;
- `query/search` 依赖异步建立的本地索引。`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说“这段范围暂时无法确认”,**不能**下“没有找到”的结论。
## 媒体消息
当 `/chatlog` 返回图片消息时:
+7 -3
View File
@@ -33,7 +33,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
- 你需要有权访问要读取的微信账号和聊天数据。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。「图片文字索引」不在此列——它在本机识别图片里的文字,不需要配置 AI。
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
@@ -134,6 +134,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
- [生成群聊日报或总结](./report.md)
- [转写微信语音](./voice.md)
- [导出聊天档案](./export.md)
- [让日报、退群通知按规则自动运行](../README.md#日报与自动化)
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
- [让外部 Agent 查询微信历史](../agent/overview.md)
@@ -159,14 +160,17 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
## 8. 需要配置 AI 吗?
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
不一定。浏览聊天、普通关键词搜索、建立本地知识库、图片文字识别、离线语音转写和导出都不要求在线 AI 服务。
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
这两种情况容易混淆:**本机识别图片里的文字**(图片文字索引)不联网、不需要 AI 服务;**让模型看图并回答**(图片理解)才需要配置 AI 服务。
## 9. 数据和隐私的最低须知
- 微信数据库、聊天解析和本地索引默认留在本机。
- 微信数据库、聊天解析、本地索引和图片文字识别默认留在本机。
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
- 图片文字索引只在本机识别,原始图片不会因为本地识别而上传;识别出的文字会进入本地索引,供搜索和“问问微信”使用。
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
+16 -2
View File
@@ -25,7 +25,7 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
### 状态怎么读
| 状态 | 含义 |
| ---- | ---- |
| ------------------- | ------------------------------------------------ |
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
@@ -38,6 +38,21 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
## 图片文字索引(另一份索引)
本地索引其实有两份,彼此独立:
- **聊天记录索引**(也就是上面说的 Knowledge):索引文字消息,用于跨会话、跨时间查找;
- **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图等),把识别结果也变成可搜索的文字。
“独立”的意思是:聊天记录索引建好了,并不代表图片里的文字就搜得到。建立图片文字索引后,可以在“问问微信”里直接搜截图或公告图里写过的词。
图片文字索引只在本机识别,原始图片不会因为本地识别而上传。它**不等于“图片理解”**:识别文字不联网、不需要 AI 服务;而让模型看图并回答属于图片理解,需要配置 AI 服务,走的是另一条路径。
识别失败的图片可以单独重试,也有“只重建搜索索引、不重新识别图片”的修复入口——修索引不需要重跑几万张图。
两个索引都可以在“设置 → 本地索引”里集中查看状态、建立、同步和清理。
## 账号隔离
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
@@ -58,4 +73,3 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
## 产品术语(可选)
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
+10 -2
View File
@@ -9,6 +9,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
- 读取和解析微信数据库;
- 聊天档案浏览和普通关键词搜索;
- 本地 Knowledge 索引及其账号隔离;
- 图片文字索引:识别图片中的文字在本机完成,原始图片不会因为本地识别而上传;
- 离线语音转写;
- 导出文件生成和本地日报历史。
@@ -16,7 +17,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
## 什么时候会请求外部服务
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。图片文字索引、离线语音转写、档案浏览和普通搜索不会触发这一步。当前设置页给出的边界是:
- 当前用户问题;
- 受控检索所需的有限上下文;
@@ -28,7 +29,14 @@ Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的
## 语音和媒体
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
离线语音转写在本机进行。
图片有两条完全不同的路径,不要混为一谈:
- **图片文字索引**:在本机识别图片里的文字,产出的是本地索引数据;原始图片不会因为这一步被上传,也不需要配置 AI 服务。
- **图片理解**:属于 AI 功能。只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。
无法读取的媒体不会被自动“猜出来”。
## Local HTTP API
+6 -2
View File
@@ -33,9 +33,11 @@
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
## 定时日报
## 定时日报(在自动化里)
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
定时日报现在是「自动化」里的一种规则,不再单独占一个页面:打开一级导航的「自动化」,新建或编辑一条「定时日报」规则,选择群聊、执行时间、日报范围、消息类型和发送目标。日报页顶部的指引条也会直接跳到自动化。
TraceMemo 会按计划执行:
```text
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
@@ -45,6 +47,8 @@
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
发送目标当前支持**当前群聊、文件传输助手、自己、指定好友**——还不是任意群发。
## 让报告更可靠
- 先选正确的群和时间范围;
+16
View File
@@ -0,0 +1,16 @@
extends: ./electron-builder.yml
extraResources:
- from: build/app-update.yml
to: app-update.yml
- from: resources
to: resources
filter:
- '**/*'
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
- '**/*'
mac:
target:
- dmg
- zip
+1
View File
@@ -34,6 +34,7 @@ extraResources:
to: resources
filter:
- '**/*'
- '!runtime/darwin-arm64/**'
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tracememo",
"version": "2.4.0",
"version": "2.5.0",
"packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [
@@ -61,6 +61,7 @@
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
"build:mac:arm64": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"build:mac:arm64:send-runtime": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && cross-env TM_SEND_RUNTIME_BUILD=1 electron-builder --config electron-builder.send-runtime.yml --mac --arm64",
"build:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
@@ -74,8 +75,10 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-darwin-x64": "3.1.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@napi-rs/system-ocr": "1.2.0",
"@napi-rs/system-ocr-darwin-x64": "1.2.0",
"@napi-rs/system-ocr-win32-x64-msvc": "1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11",
@@ -107,7 +110,9 @@
"parse5": "^8.0.0",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"sherpa-onnx-darwin-x64": "1.13.3",
"sherpa-onnx-node": "1.13.3",
"sherpa-onnx-win-x64": "1.13.4",
"silk-wasm": "^3.7.1",
"tailwind-merge": "^3.6.0",
"unzipper": "^0.12.0",
+38 -31
View File
@@ -11,8 +11,10 @@ specifiers:
'@electron-toolkit/preload': ^3.0.2
'@electron-toolkit/tsconfig': ^2.0.0
'@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-darwin-x64': 3.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@playwright/test': ^1.62.1
'@radix-ui/react-alert-dialog': ^1.1.23
@@ -72,7 +74,9 @@ specifiers:
react: ^19.2.1
react-dom: ^19.2.1
sass: ^1.102.0
sherpa-onnx-darwin-x64: 1.13.3
sherpa-onnx-node: 1.13.3
sherpa-onnx-win-x64: 1.13.4
silk-wasm: ^3.7.1
tailwind-merge: ^3.6.0
tailwindcss: 3.4.17
@@ -87,8 +91,10 @@ specifiers:
dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-darwin-x64': 3.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
@@ -120,7 +126,9 @@ dependencies:
parse5: 8.0.1
pinyin-pro: 3.29.3
qrcode: 1.5.4
sherpa-onnx-darwin-x64: 1.13.3
sherpa-onnx-node: 1.13.3
sherpa-onnx-win-x64: 1.13.4
silk-wasm: 3.7.1
tailwind-merge: 3.6.0
unzipper: 0.12.5
@@ -1585,7 +1593,6 @@ packages:
cpu: [x64]
os: [darwin]
dev: false
optional: true
/@koromix/koffi-freebsd-arm64/3.1.0:
resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==}
@@ -1689,6 +1696,36 @@ packages:
- supports-color
dev: true
/@napi-rs/system-ocr-darwin-arm64/1.2.0:
resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
dev: false
optional: true
/@napi-rs/system-ocr-darwin-x64/1.2.0:
resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
dev: false
/@napi-rs/system-ocr-win32-arm64-msvc/1.2.0:
resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
dev: false
optional: true
/@napi-rs/system-ocr-win32-x64-msvc/1.2.0:
resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
dev: false
/@napi-rs/system-ocr/1.2.0:
resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==}
engines: {node: '>= 10'}
@@ -1699,34 +1736,6 @@ packages:
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
dev: false
/@napi-rs/system-ocr-darwin-arm64/1.2.0:
resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==}
cpu: [arm64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-darwin-x64/1.2.0:
resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==}
cpu: [x64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-arm64-msvc/1.2.0:
resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==}
cpu: [arm64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-x64-msvc/1.2.0:
resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==}
cpu: [x64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@nodelib/fs.scandir/2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -7589,7 +7598,6 @@ packages:
cpu: [x64]
os: [darwin]
dev: false
optional: true
/sherpa-onnx-linux-arm64/1.13.4:
resolution: {integrity: sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==}
@@ -7628,7 +7636,6 @@ packages:
cpu: [x64]
os: [win32]
dev: false
optional: true
/side-channel-list/1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<rect
x="1.5"
y="1.5"
width="15"
height="15"
rx="3.5"
fill="none"
stroke="#000"
stroke-width="1.75"
/>
<circle cx="9" cy="9" r="2.3" fill="#000" />
</svg>

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.
+198 -18
View File
@@ -125,13 +125,62 @@ function validateSystemOcrRuntime(runtimeResources, platform, arch) {
}
}
/**
* koffi 运行期按 `${process.platform}-${process.arch}` 拼出原生包目录名
* (node_modules/koffi/src/koffi/index.cjs:153/175),找不到就直接抛
* "Cannot find the native Koffi module; did you bundle it correctly?"。
* pnpm 7 不支持 supportedArchitectures,会静默跳过外平台可选依赖,所以每个目标平台的
* koffi 原生包都必须在 package.json 里显式声明;这里再兜一层,缺了就让构建失败,
* 而不是发出一个装得上、却打不开 WCDB 的包。
*/
function koffiNativeTarget(platform, arch) {
if (platform === 'win32') {
return arch === 'x64'
? { label: 'Windows', segments: ['@koromix', 'koffi-win32-x64', 'win32_x64', 'koffi.node'] }
: null
}
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) {
return {
label: 'macOS',
segments: ['@koromix', `koffi-darwin-${arch}`, `darwin_${arch}`, 'koffi.node']
}
}
return null
}
function validateKoffiRuntime(runtimeResources, platform, arch) {
const target = koffiNativeTarget(platform, arch)
if (!target) return
const nativePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
...target.segments
)
if (!existsSync(nativePath)) {
throw new Error(`Missing ${target.label} Koffi native module: ${nativePath}`)
}
}
function normalizeBuilderArch(arch) {
if (typeof arch === 'string') return arch
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
}
function runCodesign(args) {
execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' })
try {
execFileSync('/usr/bin/codesign', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
const stderr =
error && typeof error === 'object' && 'stderr' in error ? String(error.stderr) : ''
if (stderr.trim() && error instanceof Error) {
error.message += `\n${stderr.trim()}`
}
throw error
}
}
function isMacosCodeValid(targetPath, run = runCodesign) {
@@ -174,8 +223,89 @@ function signMacosHelpers(runtimeResources, run = runCodesign) {
return helperPaths
}
/**
* codesign 只把这些位置当作「嵌套代码」并要求它们先各自签好,才肯签外层 app。
* 只遍历这一组根目录,而不是整个 bundle:Contents/Resources 下的
* app.asar.unpacked 里成千上万个原生文件不属于嵌套代码,逐个签既慢又无意义。
*/
const MACOS_CODE_LOCATIONS = [
'Frameworks',
'MacOS',
'PlugIns',
'XPCServices',
'Helpers',
'Library/LoginItems'
]
function collectNestedMacosCode(dir, depth, targets) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name)
// framework 里的 Mantle -> Versions/Current/Mantle 这类符号链接指向真实文件,
// 真实文件会在更深的层级被走到;这里跳过以免重复签名。
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
if (/\.(app|framework|xpc)$/.test(entry.name)) {
targets.push({ path: entryPath, depth, bundle: true })
}
collectNestedMacosCode(entryPath, depth + 1, targets)
continue
}
if (!entry.isFile()) continue
if (readBinaryArchitectures(entryPath).length === 0) continue
targets.push({ path: entryPath, depth, bundle: false })
}
}
/**
* 返回嵌套代码的签名顺序:深度大的先签(framework 内部的 dylib、无扩展名的
* crashpad handler 先于 framework 本身,helper 的可执行文件先于 helper app),
* 同深度时文件先于 bundle。
*/
function findNestedMacosCodePaths(appBundlePath) {
const targets = []
for (const location of MACOS_CODE_LOCATIONS) {
const root = path.join(appBundlePath, 'Contents', ...location.split('/'))
if (existsSync(root)) collectNestedMacosCode(root, 1, targets)
}
return targets
.map((target, index) => ({ ...target, index }))
.sort((a, b) => {
if (a.depth !== b.depth) return b.depth - a.depth
if (a.bundle !== b.bundle) return a.bundle ? 1 : -1
return a.index - b.index
})
.map((target) => target.path)
}
/**
* Electron 43.1.0 的 darwin-x64 官方 zip(sha256 与上游 SHASUMS256.txt 一致)
* 里所有嵌套 Mach-O 都是未签名状态,darwin-arm64 那份则是 linker-signed。
* codesign 签外层 bundle 时要求子组件已签,否则直接报
* "code object is not signed at all" + "In subcomponent: ...",
* 所以 x64 出包时只签外层必然失败,必须先由内向外补签一遍。
*
* 这里不采用 `--deep`(Apple 已标记 deprecated):它会把外层的签名选项套用到
* 所有子组件上,将来接上 Developer ID + entitlements 时会把 app 的 entitlements
* 一并套到 helper 上,属于已知的坑。
*/
function signMacosAppBundle(appBundlePath, run = runCodesign) {
if (isMacosCodeValid(appBundlePath, run)) return appBundlePath
for (const nestedPath of findNestedMacosCodePaths(appBundlePath)) {
try {
run(['--force', '--sign', '-', nestedPath])
} catch (error) {
throw new Error(
'macOS nested code signing failed: ' + path.relative(appBundlePath, nestedPath),
{ cause: error }
)
}
}
run(['--force', '--sign', '-', appBundlePath])
try {
run(['--verify', '--strict', appBundlePath])
@@ -325,9 +455,70 @@ function pruneForeignArchConnectors(runtimeResources, platform, arch) {
return removed
}
/**
* 微信发送运行时打包边界
*/
const SEND_RUNTIME_RELATIVE = ['resources', 'runtime', 'darwin-arm64']
const SEND_RUNTIME_ENTRY = 'tm-wechat-host'
function sendRuntimeLocations(runtimeResources) {
return [
path.join(runtimeResources, ...SEND_RUNTIME_RELATIVE),
path.join(runtimeResources, 'app.asar.unpacked', ...SEND_RUNTIME_RELATIVE)
]
}
function findSendRuntime(runtimeResources) {
return (
sendRuntimeLocations(runtimeResources).find((directory) =>
existsSync(path.join(directory, SEND_RUNTIME_ENTRY))
) || null
)
}
function isSendRuntimeBuild() {
return process.env.TM_SEND_RUNTIME_BUILD === '1'
}
function enforceSendRuntimeBoundary(
runtimeResources,
platform,
bundlesSendRuntime = isSendRuntimeBuild()
) {
if (platform !== 'darwin') return null
const found = findSendRuntime(runtimeResources)
if (bundlesSendRuntime) {
if (!found) {
throw new Error(
'This macOS build requires the WeChat send runtime but resources/runtime/darwin-arm64 is missing. ' +
'Run `pnpm prepare:wechat-native` first, or point TM_NATIVE_RUNTIME_DIR at the artifact.'
)
}
return found
}
if (found) {
throw new Error(
'macOS bundle must not include the WeChat send runtime: ' +
found +
'. Build with `pnpm build:mac:arm64:send-runtime` (TM_SEND_RUNTIME_BUILD=1) when it is required, ' +
'or fix the resources filter in electron-builder.yml.'
)
}
return null
}
exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context)
const arch = normalizeBuilderArch(context.arch)
// 边界先判,越早失败越好。
const sendRuntime = enforceSendRuntimeBoundary(runtimeResources, context.electronPlatformName)
if (context.electronPlatformName === 'darwin') {
console.log(
sendRuntime
? `[afterPack] send runtime bundled at ${sendRuntime}`
: '[afterPack] send runtime excluded'
)
}
validateAsarRuntimeDependencies(runtimeResources)
validateReaderSkillRuntime(runtimeResources)
validateSilkWasmRuntime(runtimeResources)
@@ -340,6 +531,7 @@ exports.default = async function afterPack(context) {
)
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
validateKoffiRuntime(runtimeResources, context.electronPlatformName, arch)
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
@@ -352,23 +544,6 @@ exports.default = async function afterPack(context) {
const productName = context.packager.appInfo.productFilename
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
}
if (context.electronPlatformName === 'win32') {
const koffiNative = path.join(
context.appOutDir,
'resources',
'app.asar.unpacked',
'node_modules',
'@koromix',
'koffi-win32-x64',
'win32_x64',
'koffi.node'
)
if (!existsSync(koffiNative)) {
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
}
return
}
}
exports.getRuntimeResources = getRuntimeResources
@@ -378,6 +553,7 @@ exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
exports.validateKoffiRuntime = validateKoffiRuntime
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
@@ -386,3 +562,7 @@ exports.findMacosHelperPaths = findMacosHelperPaths
exports.isMacosCodeValid = isMacosCodeValid
exports.signMacosHelpers = signMacosHelpers
exports.signMacosAppBundle = signMacosAppBundle
exports.findNestedMacosCodePaths = findNestedMacosCodePaths
exports.sendRuntimeLocations = sendRuntimeLocations
exports.findSendRuntime = findSendRuntime
exports.enforceSendRuntimeBoundary = enforceSendRuntimeBoundary
+415 -64
View File
@@ -1,5 +1,6 @@
import crypto from 'crypto'
import http, { IncomingMessage, ServerResponse, Server } from 'http'
import { app } from 'electron'
import {
isReady,
listContacts,
@@ -28,6 +29,11 @@ import { safeError, safeLog, safeWarn } from './safe-log'
import { apiTokenStore } from './api-token-store'
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
import { LocalQueryApiService } from './services/local-query-api-service'
import { automationRuleStore, AutomationRulePersistenceError } from './services/automation-rule-store'
import { automationExecutionLogService } from './services/automation-execution-log-service'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import { LocalAgentApiError, LocalAgentApiService } from './services/local-agent-api-service'
import type { GroupStatsService } from './services/group-stats-service'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131
@@ -45,6 +51,11 @@ interface RouteContext {
body?: unknown
}
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE'
type RouteHandler = ((ctx: RouteContext) => void | Promise<void>) & {
allowedMethods: readonly HttpMethod[]
}
export interface HttpServerOptions {
tokenProvider?: () => string | null
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
@@ -54,21 +65,56 @@ export interface HttpServerOptions {
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
scheduledReportPlatform?: NodeJS.Platform
queryApiService?: LocalQueryApiService
agentApiService?: LocalAgentApiService
groupStatsService?: Pick<GroupStatsService, 'getMemberStats'>
appVersionProvider?: () => string
}
let configuredQueryApiService: LocalQueryApiService | undefined
let configuredGroupStatsService: Pick<GroupStatsService, 'getMemberStats'> | undefined
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
configuredQueryApiService = service
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
export function setLocalGroupStatsService(
service: Pick<GroupStatsService, 'getMemberStats'> | undefined
): void {
configuredGroupStatsService = service
}
const MAX_JSON_BODY_BYTES = 1024 * 1024
class RequestBodyTooLargeError extends Error {
constructor() {
super('Request body exceeds the maximum size')
this.name = 'RequestBodyTooLargeError'
}
}
function withMethods(
methods: readonly HttpMethod[],
handler: (ctx: RouteContext) => void | Promise<void>
): RouteHandler {
return Object.assign(handler, { allowedMethods: methods })
}
function sendMethodNotAllowed(res: ServerResponse, methods: readonly HttpMethod[]): void {
res.setHeader('Allow', methods.join(', '))
sendError(res, 405, `请求方法不受支持;允许的方法:${methods.join(', ')}`)
}
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
const body = JSON.stringify(payload, null, 2)
const requestId = String(res.getHeader('X-Request-Id') || '')
let responsePayload = payload
if (status >= 400 && payload && typeof payload === 'object' && !('requestId' in payload)) {
responsePayload = { ...(payload as Record<string, unknown>), requestId }
}
const body = JSON.stringify(responsePayload, null, 2)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store'
'Cache-Control': 'no-store',
...(requestId ? { 'X-Request-Id': requestId } : {})
})
res.end(body)
}
@@ -91,8 +137,8 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
if (!isAllowedCorsOrigin(origin)) return false
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id')
return true
}
@@ -112,6 +158,20 @@ function sendUnauthorized(res: ServerResponse): void {
})
}
function sendAgentHttpError(
res: ServerResponse,
status: number,
code: string,
message: string,
details?: unknown
): void {
const requestId = String(res.getHeader('X-Request-Id') || '')
sendJson(res, status, {
error: { code, message, ...(details !== undefined ? { details } : {}) },
requestId
})
}
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
}
@@ -121,7 +181,8 @@ function sendBinary(res: ServerResponse, status: number, result: HttpImageResult
'Content-Type': result.mimeType,
'Content-Length': result.buffer.length,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
'X-Content-Type-Options': 'nosniff',
'X-Request-Id': String(res.getHeader('X-Request-Id') || '')
})
res.end(result.buffer)
}
@@ -136,10 +197,26 @@ function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string
return { ...message, contentData: safeContentData }
}
function readBody(req: IncomingMessage): Promise<string> {
function readBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<string> {
return new Promise((resolve, reject) => {
const contentLength = Number(req.headers['content-length'])
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
req.pause()
reject(new RequestBodyTooLargeError())
return
}
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
let size = 0
req.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > maxBytes) {
chunks.length = 0
req.pause()
reject(new RequestBodyTooLargeError())
return
}
chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
req.on('error', reject)
})
@@ -208,18 +285,26 @@ function parseNumeric(value: string | null, fallback: number): number {
return Number.isFinite(n) ? n : fallback
}
function getApplicationVersion(): string {
try {
return typeof app.getVersion === 'function' ? app.getVersion() : 'unknown'
} catch {
return 'unknown'
}
}
const routes: Record<string, RouteHandler> = {
'/api/v1/health': ({ res }) => {
'/api/v1/health': withMethods(['GET'], ({ res }) => {
sendJson(res, 200, {
ok: true,
ready: isReady(),
service: 'TraceMemo Reader',
version: '1.0.0',
version: getApplicationVersion(),
timestamp: new Date().toISOString()
})
},
}),
'/api/v1/current_time': ({ res }) => {
'/api/v1/current_time': withMethods(['GET'], ({ res }) => {
const now = new Date()
sendJson(res, 200, {
time: now.toISOString(),
@@ -229,9 +314,9 @@ const routes: Record<string, RouteHandler> = {
now.getDate()
).padStart(2, '0')}`
})
},
}),
'/api/v1/contact': async ({ res, url }) => {
'/api/v1/contact': withMethods(['GET'], async ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const filter = url.searchParams.get('filter') || undefined
const type = url.searchParams.get('type') || undefined
@@ -243,9 +328,9 @@ const routes: Record<string, RouteHandler> = {
contacts = contacts.filter((c) => c.type === type)
}
sendJson(res, 200, { count: contacts.length, contacts })
},
}),
'/api/v1/chatroom': async ({ res, url }) => {
'/api/v1/chatroom': withMethods(['GET'], async ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const keyword = url.searchParams.get('keyword') || ''
// Same hydration requirement as /api/v1/contact: group display names are
@@ -260,16 +345,16 @@ const routes: Record<string, RouteHandler> = {
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
},
}),
'/api/v1/recent_chat': ({ res, url }) => {
'/api/v1/recent_chat': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const limit = parseNumeric(url.searchParams.get('limit'), 50)
const items = listRecentChat(limit)
sendJson(res, 200, { count: items.length, items })
},
}),
'/api/v1/chatlog': ({ res, url }) => {
'/api/v1/chatlog': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const talker = url.searchParams.get('talker')
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
@@ -307,27 +392,27 @@ const routes: Record<string, RouteHandler> = {
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
)
})
},
}),
'/api/v1/group_snapshot': ({ res, url }) => {
'/api/v1/group_snapshot': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const md5 = url.searchParams.get('md5')
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
const snapshot = getGroupSnapshot(md5)
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
sendJson(res, 200, snapshot)
},
}),
'/api/v1/resolve': ({ res, url }) => {
'/api/v1/resolve': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const q = url.searchParams.get('q')
if (!q) return sendError(res, 400, '缺少必要参数 q')
const contact = resolveMd5(q)
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
sendJson(res, 200, contact)
},
}),
'/api/v1/report': async ({ req, res, body }) => {
'/api/v1/report': withMethods(['POST'], async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
if (typeof body !== 'string' || !body.trim()) {
@@ -354,9 +439,9 @@ const routes: Record<string, RouteHandler> = {
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
},
}),
'/api/v1/agent/group-report': async ({ req, res, body }) => {
'/api/v1/agent/group-report': withMethods(['POST'], async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
@@ -370,9 +455,9 @@ const routes: Record<string, RouteHandler> = {
range: request.range
})
sendJson(res, result.success ? 200 : 400, result)
},
}),
'/api/v1/agent/status': ({ res }) => {
'/api/v1/agent/status': withMethods(['GET'], ({ res }) => {
const status = agentHubService.getStatus()
sendJson(res, 200, {
ok: status.hub === 'online' && status.connector === 'online',
@@ -382,9 +467,9 @@ const routes: Record<string, RouteHandler> = {
databaseReady: status.databaseReady,
accountId: status.accountId
})
},
}),
'/api/v1/agent/send': async ({ req, res, body }) => {
'/api/v1/agent/send': withMethods(['POST'], async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let request: { to?: string; text?: string; media_url?: string }
try {
@@ -398,7 +483,7 @@ const routes: Record<string, RouteHandler> = {
mediaUrl: request.media_url
})
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
}
})
}
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
@@ -448,18 +533,18 @@ function createScheduledReportRoute(
api: ScheduledReportApiService
): RouteHandler | undefined {
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
return async ({ req, res }) => {
return withMethods(['GET'], 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 }) => {
return withMethods(['GET', 'POST'], async ({ req, res, body }) => {
try {
if (req.method === 'GET') {
const tasks = await api.list()
@@ -475,7 +560,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
}
})
}
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
@@ -488,7 +573,7 @@ function createScheduledReportRoute(
} catch {
return undefined
}
return async ({ req, res }) => {
return withMethods(['POST'], async ({ req, res }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
try {
const execution = await api.retrySend(executionId)
@@ -496,7 +581,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
}
})
}
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
@@ -510,8 +595,15 @@ function createScheduledReportRoute(
return undefined
}
const action = segments[1]
if (action && !['enable', 'disable', 'run', 'executions'].includes(action)) return undefined
return async ({ req, res, body }) => {
const methods: HttpMethod[] = !action
? ['GET', 'PATCH', 'DELETE']
: action === 'executions'
? ['GET']
: ['POST']
return withMethods(methods, async ({ req, res, body }) => {
try {
if (!action && req.method === 'GET') {
sendJson(res, 200, { task: await api.get(taskId) })
@@ -551,7 +643,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
}
})
}
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
@@ -566,42 +658,46 @@ function queryStatusCode(status: string): number {
return 400
}
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
return async ({ req, res, body }) => {
const pathname = new URL(req.url || '/', 'http://localhost').pathname
function createQueryRoute(pathname: string, api: LocalQueryApiService): RouteHandler | undefined {
if (pathname === '/api/v1/query/capabilities') {
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
return withMethods(['GET'], ({ res }) => {
return sendJson(res, 200, api.capabilities())
})
}
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let payload: any
type QueryOperationResult =
| Awaited<ReturnType<LocalQueryApiService['messages']>>
| Awaited<ReturnType<LocalQueryApiService['search']>>
| Awaited<ReturnType<LocalQueryApiService['context']>>
| Awaited<ReturnType<LocalQueryApiService['overview']>>
const operations: Record<string, (payload: unknown) => Promise<QueryOperationResult>> = {
'/api/v1/query/messages': (payload) =>
api.messages(payload as Parameters<LocalQueryApiService['messages']>[0]),
'/api/v1/query/search': (payload) =>
api.search(payload as Parameters<LocalQueryApiService['search']>[0]),
'/api/v1/query/message-context': (payload) =>
api.context(payload as Parameters<LocalQueryApiService['context']>[0]),
'/api/v1/query/conversation-overview': (payload) =>
api.overview(payload as Parameters<LocalQueryApiService['overview']>[0])
}
const operation = operations[pathname]
if (!operation) return undefined
return withMethods(['POST'], async ({ res, body }) => {
let payload: unknown
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}`)
const result = await operation(payload)
return sendJson(res, queryStatusCode(result.status), result)
} catch (error) {
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
}
}
})
}
function createMediaRoute(
mediaProvider: (messageId: string) => Promise<HttpImageResult>
): RouteHandler {
return async ({ req, res, url }) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return sendError(res, 405, '需要 GET 请求')
}
return withMethods(['GET', 'HEAD'], async ({ req, res, url }) => {
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
let messageId: string
try {
@@ -640,7 +736,218 @@ function createMediaRoute(
safeError('[HttpServer] media request failed:', error)
return sendError(res, 500, '图片读取失败')
}
})
}
function createAgentApiService(options: HttpServerOptions): LocalAgentApiService {
const groupStats = options.groupStatsService || configuredGroupStatsService
return options.agentApiService || new LocalAgentApiService({
automationRuleStore,
automationExecutionLogService,
listContacts: listContactsAsync,
isDatabaseReady: isReady,
getVersion: options.appVersionProvider || getApplicationVersion,
getPersonalWechatCapability: () =>
personalWechatCapabilityService.getPersonalWechatSendCapability(),
getAgentHubStatus: () => agentHubService.getStatus(),
getGroupExitMonitorState: () => {
const state = groupExitMonitorService.getState()
return { ...state, monitoredRoomIds: state.monitoredRoomIds || [] }
},
configureGroupExitMonitor: (configuration) =>
groupExitMonitorService.configure(configuration),
setGroupExitMonitorRoomIds: (roomIds) => groupExitMonitorService.setMonitoredRoomIds(roomIds),
setGroupExitMonitorEnabled: (enabled) => groupExitMonitorService.setEnabled(enabled),
listGroupExitMonitorEvents: (query) => groupExitMonitorService.listEvents(query),
...(groupStats ? { getGroupMemberStats: (query) => groupStats.getMemberStats(query) } : {})
})
}
function sendAgentApiError(res: ServerResponse, error: unknown): void {
const requestId = String(res.getHeader('X-Request-Id') || '')
if (error instanceof LocalAgentApiError) {
sendAgentHttpError(res, error.status, error.code, error.message, error.details)
return
}
if (error instanceof AutomationRulePersistenceError) {
sendAgentHttpError(res, 500, 'PERSISTENCE_FAILED', '自动化规则未能保存到本地')
return
}
safeError(`[HttpServer requestId=${requestId}] Agent API request failed:`, error)
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
}
async function handleAgentApi<T>(
res: ServerResponse,
operation: () => Promise<T> | T,
respond: (value: T) => void
): Promise<void> {
try {
respond(await operation())
} catch (error) {
sendAgentApiError(res, error)
}
}
function parseAgentJson(body: unknown): unknown {
if (typeof body !== 'string' || !body.trim()) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体不能为空')
}
try {
return JSON.parse(body)
} catch {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体 JSON 格式无效')
}
}
function createAgentApiRoute(
pathname: string,
api: LocalAgentApiService
): RouteHandler | undefined {
if (pathname === '/api/v1/capabilities') {
return withMethods(['GET'], ({ res }) =>
void handleAgentApi(res, () => api.getCapabilities(), (capabilities) =>
sendJson(res, 200, capabilities)
)
)
}
const groupExitMonitorPath = '/api/v1/monitors/group-exits'
if (pathname === groupExitMonitorPath) {
return withMethods(['GET', 'PATCH'], async ({ req, res, body }) => {
if (req.method === 'GET') {
await handleAgentApi(res, () => api.getGroupExitMonitorState(), (state) =>
sendJson(res, 200, state)
)
return
}
await handleAgentApi(res, () => api.updateGroupExitMonitor(parseAgentJson(body)), (state) =>
sendJson(res, 200, state)
)
})
}
if (pathname === `${groupExitMonitorPath}/events`) {
return withMethods(['GET'], ({ res, url }) =>
void handleAgentApi(res, () => api.listGroupExitMonitorEvents(url.searchParams), (result) =>
sendJson(res, 200, result)
)
)
}
const groupStatsPrefix = '/api/v1/groups/'
if (pathname.startsWith(groupStatsPrefix)) {
const segments = pathname.slice(groupStatsPrefix.length).split('/')
if (segments.length === 2 && segments[1] === 'member-stats' && segments[0]) {
let conversationId: string
try {
conversationId = decodeURIComponent(segments[0])
} catch {
return undefined
}
return withMethods(['GET'], ({ res, url }) =>
void handleAgentApi(
res,
() => api.getGroupMemberStats(conversationId, url.searchParams),
(result) => sendJson(res, 200, result)
)
)
}
}
const automationCollection = '/api/v1/automations'
if (pathname === automationCollection) {
return withMethods(['GET', 'POST'], async ({ req, res, url, body }) => {
if (req.method === 'GET') {
await handleAgentApi(
res,
() => api.listAutomations({
type: url.searchParams.get('type'),
enabled: url.searchParams.get('enabled')
}),
(rules) => sendJson(res, 200, { count: rules.length, rules })
)
return
}
await handleAgentApi(res, () => api.createAutomation(parseAgentJson(body)), (rule) =>
sendJson(res, 201, { created: true, rule })
)
})
}
if (pathname === `${automationCollection}/validate`) {
return withMethods(['POST'], async ({ res, body }) => {
await handleAgentApi(res, () => api.validateAutomation(parseAgentJson(body)), (result) =>
sendJson(res, 200, result)
)
})
}
if (pathname === `${automationCollection}/executions`) {
return withMethods(['GET'], ({ res, url }) =>
void handleAgentApi(res, () => api.listExecutions(url.searchParams), (result) =>
sendJson(res, 200, result)
)
)
}
const prefix = `${automationCollection}/`
if (!pathname.startsWith(prefix)) return undefined
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
if (segments.length < 1 || segments.length > 2) return undefined
let id: string
try {
id = decodeURIComponent(segments[0])
} catch {
return undefined
}
if (!id || id.includes('/') || id.includes('\\')) return undefined
const action = segments[1]
if (action && action !== 'enable' && action !== 'disable') return undefined
if (action) {
return withMethods(['POST'], ({ res }) =>
void handleAgentApi(res, () => api.setAutomationEnabled(id, action === 'enable'), (rule) =>
sendJson(res, 200, { updated: true, rule })
)
)
}
return withMethods(['GET', 'PATCH', 'DELETE'], async ({ req, res, body }) => {
if (req.method === 'GET') {
await handleAgentApi(res, () => api.getAutomation(id), (rule) =>
sendJson(res, 200, { rule })
)
return
}
if (req.method === 'PATCH') {
await handleAgentApi(
res,
() => api.updateAutomation(id, parseAgentJson(body)),
(rule) => sendJson(res, 200, { updated: true, rule })
)
return
}
await handleAgentApi(res, () => api.deleteAutomation(id), (result) =>
sendJson(res, 200, { deleted: true, ...result })
)
})
}
function requestIdFor(req: IncomingMessage): string {
const incoming = req.headers['x-request-id']
return typeof incoming === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(incoming)
? incoming
: crypto.randomUUID()
}
function isAgentApiPath(pathname: string): boolean {
return pathname === '/api/v1/capabilities' ||
pathname === '/api/v1/monitors/group-exits' ||
pathname.startsWith('/api/v1/monitors/group-exits/') ||
pathname.startsWith('/api/v1/groups/') ||
pathname === '/api/v1/automations' ||
pathname.startsWith('/api/v1/automations/')
}
export function startHttpServer(
@@ -652,11 +959,19 @@ export function startHttpServer(
const mediaProvider = options.mediaProvider || readImageMedia
const scheduledReportApi = createScheduledReportApi(options)
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
const agentApi = createAgentApiService(options)
return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => {
const requestId = requestIdFor(req)
res.setHeader('X-Request-Id', requestId)
let agentApiRequest = false
try {
const url = new URL(req.url || '/', `http://${host}:${port}`)
agentApiRequest = isAgentApiPath(url.pathname)
if (!applyCorsHeaders(req, res)) {
if (agentApiRequest) {
return sendAgentHttpError(res, 403, 'FORBIDDEN', 'Origin 不允许访问本地 API')
}
return sendError(res, 403, 'Origin 不允许访问本地 API')
}
if (req.method === 'OPTIONS') {
@@ -665,15 +980,37 @@ export function startHttpServer(
}
const handler =
routes[url.pathname] ||
createAgentApiRoute(url.pathname, agentApi) ||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX)
? createQueryRoute(url.pathname, queryApi)
: undefined) ||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
? createMediaRoute(mediaProvider)
: undefined)
if (!handler) {
if (agentApiRequest) {
return sendAgentHttpError(res, 404, 'NOT_FOUND', `端点不存在: ${url.pathname}`)
}
return sendError(res, 404, `端点不存在: ${url.pathname}`)
}
if (!handler.allowedMethods.includes(req.method as HttpMethod)) {
if (agentApiRequest) {
res.setHeader('Allow', handler.allowedMethods.join(', '))
return sendAgentHttpError(
res,
405,
'METHOD_NOT_ALLOWED',
`请求方法不受支持;允许的方法:${handler.allowedMethods.join(', ')}`,
{ allowedMethods: handler.allowedMethods }
)
}
return sendMethodNotAllowed(res, handler.allowedMethods)
}
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
if (agentApiRequest) {
return sendAgentHttpError(res, 401, 'UNAUTHORIZED', 'Valid API token required')
}
return sendUnauthorized(res)
}
let body: string | undefined
@@ -683,8 +1020,22 @@ export function startHttpServer(
const ctx: RouteContext = { req, res, url, body }
await handler(ctx)
} catch (error) {
safeError('[HttpServer] 请求处理失败:', error)
if (error instanceof RequestBodyTooLargeError) {
res.setHeader('Connection', 'close')
res.shouldKeepAlive = false
if (agentApiRequest) {
sendAgentHttpError(res, 413, 'PAYLOAD_TOO_LARGE', '请求体不能超过 1 MiB')
return
}
sendError(res, 413, '请求体不能超过 1 MiB')
return
}
safeError(`[HttpServer requestId=${requestId}] 请求处理失败:`, error)
if (!res.headersSent) {
if (agentApiRequest) {
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
return
}
sendError(res, 500, error instanceof Error ? error.message : String(error))
}
}
+23 -8
View File
@@ -21,7 +21,10 @@ import { dirname, extname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import trayTemplateIcon from '../../resources/trayTemplate.png?asset'
import trayTemplateRetinaIcon from '../../resources/trayTemplate@2x.png?asset'
import { WechatDb } from './wechat-db'
import { createTrayImage } from './tray-icon'
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
@@ -73,7 +76,7 @@ import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../
import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service'
import { apiServer, setLocalQueryApiService } from './http-server'
import { apiServer, setLocalGroupStatsService, setLocalQueryApiService } from './http-server'
import { skillResourceService } from './services/skill-resource-service'
import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service'
import { isWechatRunning } from './services/wechat-process-status'
@@ -370,6 +373,18 @@ function configureRecallProtection(
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
const packagedTrayTemplateIconPath = join(process.resourcesPath, 'resources', 'trayTemplate.png')
const packagedTrayTemplateRetinaIconPath = join(
process.resourcesPath,
'resources',
'trayTemplate@2x.png'
)
const trayTemplateIconPath = existsSync(packagedTrayTemplateIconPath)
? packagedTrayTemplateIconPath
: trayTemplateIcon
const trayTemplateRetinaIconPath = existsSync(packagedTrayTemplateRetinaIconPath)
? packagedTrayTemplateRetinaIconPath
: trayTemplateRetinaIcon
protocol.registerSchemesAsPrivileged([
{
@@ -790,6 +805,7 @@ app.whenReady().then(async () => {
// 群员统计复用同一个 Knowledge 实例:它只是「读派生库 + 读成员名单」的编排,
// 不持有自己的数据库,也不新建索引。
groupStatsService = new GroupStatsService(knowledgeSearchService)
setLocalGroupStatsService(groupStatsService)
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
localQueryApiService.setImageTextCoverageProvider(() =>
@@ -921,8 +937,6 @@ app.whenReady().then(async () => {
// 设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.tracememo.app')
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
// 开发环境中默认使用 F12 打开或关闭 DevTools
// 生产环境中忽略 CommandOrControl + R
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -2875,11 +2889,12 @@ function buildTrayMenu(): Menu {
function setupTray(): void {
if (tray) return
try {
const image = nativeImage.createFromPath(appIconPath)
const traySize = process.platform === 'darwin' ? 20 : 24
const trayImage = image.isEmpty()
? nativeImage.createEmpty()
: image.resize({ width: traySize, height: traySize, quality: 'best' })
const trayImage = createTrayImage(
process.platform,
appIconPath,
{ oneX: trayTemplateIconPath, twoX: trayTemplateRetinaIconPath },
nativeImage
)
tray = new Tray(trayImage)
tray.setToolTip('TraceMemo')
// macOS may show a Tray context menu on a primary click when it is set
+36 -12
View File
@@ -50,6 +50,13 @@ const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json'
const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json'
const CURRENT_VERSION = 4
export class AutomationRulePersistenceError extends Error {
constructor() {
super('自动化规则保存失败')
this.name = 'AutomationRulePersistenceError'
}
}
interface StoredRules {
version: number
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
@@ -231,7 +238,8 @@ export class AutomationRuleStore {
this.legacyConversationResolver = resolver
if (!this.loaded) return
if (this.state.scheduledReportMigrated) return
if (this.runScheduledReportMigration(this.state)) this.persist()
const next = structuredClone(this.state)
if (this.runScheduledReportMigration(next)) this.commitState(next)
}
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
@@ -274,8 +282,7 @@ export class AutomationRuleStore {
createdAt: timestamp,
updatedAt: timestamp
}
this.state.rules = [...this.state.rules, rule]
this.persist()
this.commitRules([...this.state.rules, rule])
return structuredClone(rule)
}
@@ -316,8 +323,7 @@ export class AutomationRuleStore {
} else {
delete rule.leaveNotification
}
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
this.persist()
this.commitRules(this.state.rules.map((item, at) => (at === index ? rule : item)))
return structuredClone(rule)
}
@@ -346,8 +352,7 @@ export class AutomationRuleStore {
createdAt: timestamp,
updatedAt: timestamp
}
this.state.rules = [...this.state.rules, rule]
this.persist()
this.commitRules([...this.state.rules, rule])
return structuredClone(rule)
}
@@ -355,9 +360,9 @@ export class AutomationRuleStore {
this.ensureLoaded()
const key = String(id || '').trim()
const before = this.state.rules.length
this.state.rules = this.state.rules.filter((rule) => rule.id !== key)
if (this.state.rules.length === before) return false
this.persist()
const rules = this.state.rules.filter((rule) => rule.id !== key)
if (rules.length === before) return false
this.commitRules(rules)
return true
}
@@ -394,8 +399,7 @@ export class AutomationRuleStore {
},
updatedAt: this.now()
}
this.state.rules = this.state.rules.map((item, at) => (at === index ? next : item))
this.persist()
this.commitRules(this.state.rules.map((item, at) => (at === index ? next : item)))
return structuredClone(next)
}
@@ -428,7 +432,11 @@ export class AutomationRuleStore {
this.runScheduledReportMigration(stored)
stored.version = CURRENT_VERSION
this.state = stored
try {
this.persist()
} catch {
// Keep the in-memory defaults available; later writes report persistence failures.
}
}
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
@@ -641,6 +649,22 @@ export class AutomationRuleStore {
console.warn(
`[Automation] 保存规则失败: ${error instanceof Error ? error.message : String(error)}`
)
throw new AutomationRulePersistenceError()
}
}
private commitRules(rules: AutomationRule[]): void {
this.commitState({ ...this.state, rules })
}
private commitState(next: StoredRules): void {
const previous = this.state
this.state = next
try {
this.persist()
} catch (error) {
this.state = previous
throw error
}
}
}
+36 -13
View File
@@ -237,11 +237,31 @@ class GroupExitMonitorService {
* 本服务不再持有任何通知配置。
*/
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
return this.configure({ monitoredRoomIds: roomIds })
}
/**
* Atomically update the monitor scope and enabled state.
*
* Agent API PATCH validates the whole request before calling this method. Keeping the
* two mutations in one service operation also means persistence and the renderer broadcast
* observe one final configuration rather than an intermediate half-patched state.
*/
async configure(configuration: {
monitoredRoomIds?: string[]
enabled?: boolean
}): Promise<GroupExitMonitorState> {
this.ensureLoaded()
const hasScope = configuration.monitoredRoomIds !== undefined
const hasEnabled = configuration.enabled !== undefined
if (!hasScope && !hasEnabled) return this.getState()
if (hasEnabled && !hasScope && this.enabled === configuration.enabled) return this.getState()
if (hasScope) {
this.eventSequence += 1
this.scopeGeneration += 1
this.monitorSelectionConfigured = true
const nextMonitoredRoomIds = normalizeRoomIds(roomIds)
const nextMonitoredRoomIds = normalizeRoomIds(configuration.monitoredRoomIds || [])
for (const roomId of this.snapshots.keys()) {
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
}
@@ -251,18 +271,10 @@ class GroupExitMonitorService {
this.monitoredRoomIds = nextMonitoredRoomIds
this.groupNamesRefreshPending = true
this.lastCheckedAt = undefined
this.save()
this.broadcast()
// 建立基线放到后台,保存配置可以立即返回。
if (this.enabled && this.active) void this.check()
return this.getState()
}
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
this.ensureLoaded()
if (this.enabled === enabled) return this.getState()
this.enabled = enabled
if (hasEnabled && this.enabled !== configuration.enabled) {
this.enabled = configuration.enabled === true
this.eventSequence += 1
this.scopeGeneration += 1
this.checkQueued = false
@@ -270,18 +282,29 @@ class GroupExitMonitorService {
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
if (enabled) {
if (this.enabled) {
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
this.snapshots.clear()
this.lastCheckedAt = undefined
this.groupNamesRefreshPending = true
}
}
this.save()
this.broadcast()
if (enabled && this.active && chat.isReady()) await this.check()
// 仅修改范围时保持原有的后台基线行为;涉及 enabled 的 PATCH 等待一次检查,
// 让调用方拿到的是最终运行状态。
if (this.enabled && this.active && chat.isReady()) {
if (hasEnabled) await this.check()
else void this.check()
}
return this.getState()
}
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
return this.configure({ enabled })
}
/**
* 按群 / 时间范围查退群事件(档案合并展示用)。
*
@@ -0,0 +1,967 @@
import {
BUILTIN_DAILY_REPORT_RULE_ID,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
calculateNextRunAt,
type AutomationExecution,
type AutomationRule,
type AutomationRuleDraft,
type AutomationRuleType
} from '../../shared/automation'
import { resolveContact } from './contact-resolution-service'
import { validateAutomationDraftShape } from '../../shared/agent-api/automation-validation'
import type {
AgentAutomationExecution,
AgentAutomationValidationIssue,
AgentAutomationValidationResult,
ApplicationCapabilities
} from '../../shared/agent-api/contracts'
import type {
AgentGroupExitMonitorEvent,
AgentGroupExitMonitorState
} from '../../shared/agent-api/group-exit-monitor'
import type { AgentGroupMemberStats } from '../../shared/agent-api/group-stats'
import type { GroupExitMonitorEvent } from '../../shared/group-exit-monitor'
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../../shared/group-stats'
import type { Contact } from '../../shared/types'
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
import type { AutomationExecutionLogService } from './automation-execution-log-service'
import type { AutomationRuleStore } from './automation-rule-store'
type AutomationRuleStoreApi = Pick<
AutomationRuleStore,
'listRules' | 'getRule' | 'createRule' | 'updateRule' | 'deleteRule' | 'setRuleEnabled'
>
type AutomationExecutionLogApi = Pick<AutomationExecutionLogService, 'list'>
type RuleScope = 'any' | 'person' | 'group'
type GroupExitMonitorStateSource = {
enabled: boolean
running: boolean
monitoredRoomIds?: string[]
nativeMonitorActive?: boolean
monitoredGroupCount?: number
monitorSelectionConfigured?: boolean
lastCheckedAt?: number
lastReadAt?: number
unreadCount?: number
totalEventCount?: number
events?: GroupExitMonitorEvent[]
}
type GroupExitMonitorConfiguration = {
enabled?: boolean
monitoredRoomIds?: string[]
}
type GroupExitMonitorEventQuery = {
roomId?: string
sinceMs?: number
untilMs?: number
limit?: number
}
export class LocalAgentApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown
) {
super(message)
this.name = 'LocalAgentApiError'
}
}
export interface LocalAgentApiDependencies {
automationRuleStore: AutomationRuleStoreApi
automationExecutionLogService: AutomationExecutionLogApi
listContacts: () => Promise<Contact[]>
isDatabaseReady: () => boolean
getVersion: () => string
getPersonalWechatCapability: () => Promise<PersonalWechatSendCapability>
getAgentHubStatus: () => { connector: string }
getGroupExitMonitorState: () => GroupExitMonitorStateSource
/** Preferred atomic monitor configuration operation. */
configureGroupExitMonitor?: (
configuration: GroupExitMonitorConfiguration
) => Promise<GroupExitMonitorStateSource> | GroupExitMonitorStateSource
setGroupExitMonitorRoomIds?: (roomIds: string[]) => Promise<GroupExitMonitorStateSource>
setGroupExitMonitorEnabled?: (enabled: boolean) => Promise<GroupExitMonitorStateSource>
listGroupExitMonitorEvents?: (query: GroupExitMonitorEventQuery) => GroupExitMonitorEvent[]
getGroupMemberStats?: (query: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
}
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'scheduled_report', 'leave_notification']
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
const UPDATE_FIELDS = new Set([
'name',
'trigger',
'scope',
'conditions',
'actions',
'cooldownSeconds',
'replyDelaySeconds',
'leaveNotification',
'scheduledReport'
])
function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
function toDraft(rule: AutomationRule): AutomationRuleDraft {
return {
name: rule.name,
enabled: false,
ruleType: rule.ruleType,
trigger: rule.trigger,
scope: rule.scope,
conditions: structuredClone(rule.conditions),
actions: structuredClone(rule.actions),
cooldownSeconds: rule.cooldownSeconds,
replyDelaySeconds: rule.replyDelaySeconds,
...(rule.leaveNotification
? { leaveNotification: structuredClone(rule.leaveNotification) }
: {}),
...(rule.scheduledReport ? { scheduledReport: structuredClone(rule.scheduledReport) } : {})
}
}
function deepMergeDraft(
current: AutomationRuleDraft,
patch: Record<string, unknown>
): Record<string, unknown> {
const next: Record<string, unknown> = { ...current, ...patch, enabled: false }
if (patch.conditions !== undefined) {
next.conditions = { ...current.conditions, ...record(patch.conditions) }
}
if (patch.leaveNotification !== undefined && current.leaveNotification) {
const leavePatch = record(patch.leaveNotification) || {}
next.leaveNotification = {
...current.leaveNotification,
...leavePatch,
...(leavePatch.target !== undefined ? { target: leavePatch.target } : {})
}
if (record(leavePatch.target)?.type !== undefined) {
delete (next.leaveNotification as Record<string, unknown>).targetNeedsReview
}
}
if (patch.scheduledReport !== undefined && current.scheduledReport) {
const schedulePatch = record(patch.scheduledReport) || {}
const currentConfig = current.scheduledReport
next.scheduledReport = {
...currentConfig,
...schedulePatch,
schedule: { ...currentConfig.schedule, ...record(schedulePatch.schedule) },
report: { ...currentConfig.report, ...record(schedulePatch.report) },
...(schedulePatch.target !== undefined ? { target: schedulePatch.target } : {})
}
if (record(schedulePatch.target)?.type !== undefined) {
delete (next.scheduledReport as Record<string, unknown>).targetNeedsReview
}
}
return next
}
function stableId(contact: Contact): string {
return contact.type === 'user' ? contact.wxid || contact.m_nsUsrName : contact.m_nsUsrName
}
function toIsoTimestamp(value: number | undefined): string | null {
if (!Number.isFinite(value) || Number(value) <= 0) return null
const date = new Date(Number(value))
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
export class LocalAgentApiService {
private readonly deps: LocalAgentApiDependencies
constructor(dependencies: LocalAgentApiDependencies) {
this.deps = dependencies
}
async getCapabilities(): Promise<ApplicationCapabilities> {
const databaseReady = this.deps.isDatabaseReady()
const hub = this.deps.getAgentHubStatus()
const monitor = this.deps.getGroupExitMonitorState()
let personal: Pick<
PersonalWechatSendCapability,
'supported' | 'ready' | 'status' | 'capabilities'
>
try {
personal = await this.deps.getPersonalWechatCapability()
} catch {
personal = {
supported: true,
ready: false,
status: 'error',
capabilities: { text: false, image: false, voice: false }
}
}
return {
version: this.deps.getVersion(),
apiVersion: 'v1',
database: { ready: databaseReady },
query: {
supported: true,
available: databaseReady,
...(!databaseReady ? { reason: 'database_not_ready' } : {})
},
automations: {
supported: true,
available: true,
ruleTypes: [...RULE_TYPES],
operations: [
'list',
'get',
'create',
'update',
'validate',
'enable',
'disable',
'delete',
'executions'
]
},
groupExitMonitor: {
supported: true,
available: databaseReady && monitor.running,
operations: [
'read_state',
'configure_scope',
'enable',
'disable',
'list_events'
],
...(!databaseReady
? { reason: 'database_not_ready' }
: !monitor.enabled
? { reason: 'disabled' }
: !monitor.running
? { reason: 'not_running' }
: {})
},
groupStats: {
supported: true,
available: databaseReady,
operations: ['member_stats'],
...(!databaseReady ? { reason: 'database_not_ready' } : {})
},
wechat: {
personal: {
supported: personal.supported,
available: personal.ready,
status: personal.status,
content: { ...personal.capabilities },
...(!personal.ready ? { reason: personal.status } : {})
},
ilink: {
supported: true,
available: hub.connector === 'online',
status: hub.connector,
...(hub.connector !== 'online' ? { reason: 'connector_not_online' } : {})
}
}
}
}
getGroupExitMonitorState(): AgentGroupExitMonitorState {
const state = this.deps.getGroupExitMonitorState()
return {
enabled: state.enabled === true,
running: state.running === true,
nativeMonitorActive: state.nativeMonitorActive === true,
monitoredConversationIds: [...(state.monitoredRoomIds || [])],
monitoredGroupCount: Number.isFinite(state.monitoredGroupCount)
? Number(state.monitoredGroupCount)
: state.monitoredRoomIds?.length || 0,
monitorSelectionConfigured: state.monitorSelectionConfigured !== false,
lastCheckedAt: toIsoTimestamp(state.lastCheckedAt),
lastReadAt: toIsoTimestamp(state.lastReadAt),
eventCount: Number.isFinite(state.totalEventCount)
? Number(state.totalEventCount)
: state.events?.length || 0,
unreadCount: Number.isFinite(state.unreadCount) ? Number(state.unreadCount) : 0
}
}
async updateGroupExitMonitor(input: unknown): Promise<AgentGroupExitMonitorState> {
const patch = record(input)
if (!patch) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体必须是 JSON 对象')
}
const keys = Object.keys(patch)
const allowed = new Set(['enabled', 'monitoredConversationIds'])
const unknown = keys.find((key) => !allowed.has(key))
if (unknown) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `不支持字段:${unknown}`)
}
if (keys.length === 0) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '至少提供 enabled 或 monitoredConversationIds')
}
const enabled = patch.enabled
if (enabled !== undefined && typeof enabled !== 'boolean') {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'enabled 必须是布尔值')
}
let monitoredRoomIds: string[] | undefined
if (patch.monitoredConversationIds !== undefined) {
if (!Array.isArray(patch.monitoredConversationIds)) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'monitoredConversationIds 必须是数组')
}
monitoredRoomIds = []
const seen = new Set<string>()
for (const value of patch.monitoredConversationIds) {
if (typeof value !== 'string' || !value.trim()) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '监控群 ID 不能为空')
}
const roomId = value.trim()
if (seen.has(roomId)) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `监控群 ID 重复:${roomId}`)
}
if (!roomId.endsWith('@chatroom') || roomId.includes('/') || roomId.includes('\\')) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `无效的群会话 ID:${roomId}`)
}
seen.add(roomId)
monitoredRoomIds.push(roomId)
}
}
if (!this.deps.isDatabaseReady()) {
throw new LocalAgentApiError(409, 'DATABASE_NOT_READY', '数据库未就绪,无法修改退群监控配置')
}
if (monitoredRoomIds !== undefined) {
const contacts = await this.deps.listContacts()
const groups = new Set(
contacts
.filter((contact) => contact.type === 'group')
.map((contact) => contact.m_nsUsrName)
)
const missing = monitoredRoomIds.find((roomId) => !groups.has(roomId))
if (missing) {
throw new LocalAgentApiError(
422,
'VALIDATION_FAILED',
`监控群不存在或不是群联系人:${missing}`,
[{ path: 'monitoredConversationIds', code: 'group_not_found', message: missing }]
)
}
}
const configuration: GroupExitMonitorConfiguration = {
...(enabled !== undefined ? { enabled } : {}),
...(monitoredRoomIds !== undefined ? { monitoredRoomIds } : {})
}
let nextState: GroupExitMonitorStateSource | undefined
if (this.deps.configureGroupExitMonitor) {
nextState = await this.deps.configureGroupExitMonitor(configuration)
} else {
// Test adapters and older embedders may only expose the two primitive operations.
// All validation is complete before this fallback starts mutating state.
const previous = this.deps.getGroupExitMonitorState()
try {
if (monitoredRoomIds !== undefined) {
if (!this.deps.setGroupExitMonitorRoomIds) {
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群监控配置能力尚未就绪')
}
nextState = await this.deps.setGroupExitMonitorRoomIds(monitoredRoomIds)
}
if (enabled !== undefined) {
if (!this.deps.setGroupExitMonitorEnabled) {
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群监控配置能力尚未就绪')
}
nextState = await this.deps.setGroupExitMonitorEnabled(enabled)
}
} catch (error) {
// Best-effort rollback for legacy adapters. Production uses the atomic operation above.
try {
if (monitoredRoomIds !== undefined && this.deps.setGroupExitMonitorRoomIds) {
await this.deps.setGroupExitMonitorRoomIds(previous.monitoredRoomIds || [])
}
if (enabled !== undefined && this.deps.setGroupExitMonitorEnabled) {
await this.deps.setGroupExitMonitorEnabled(previous.enabled)
}
} catch {
// Preserve the original error; an adapter that cannot roll back is non-atomic by definition.
}
throw error
}
}
return this.toGroupExitMonitorState(nextState || this.deps.getGroupExitMonitorState())
}
listGroupExitMonitorEvents(query: URLSearchParams): {
count: number
events: AgentGroupExitMonitorEvent[]
} {
if (!this.deps.listGroupExitMonitorEvents) {
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群事件查询能力尚未就绪')
}
const rawConversationId = query.get('conversationId')
let roomId: string | undefined
if (rawConversationId !== null) {
roomId = rawConversationId.trim()
if (!roomId || !roomId.endsWith('@chatroom') || roomId.includes('/') || roomId.includes('\\')) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'conversationId 必须是有效的群会话 ID')
}
}
const since = this.parseDateFilter(query.get('since'), 'since')
const until = this.parseDateFilter(query.get('until'), 'until')
if (since !== undefined && until !== undefined && since > until) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'since 不能晚于 until')
}
const rawLimit = query.get('limit')
const limit = rawLimit === null ? 50 : Number(rawLimit)
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'limit 必须是 1 到 200 之间的整数')
}
const events = this.deps
.listGroupExitMonitorEvents({ roomId, sinceMs: since, untilMs: until, limit })
.slice()
.sort((left, right) => left.detectedAt - right.detectedAt)
.map((event) => this.toAgentGroupExitEvent(event))
return { count: events.length, events }
}
async getGroupMemberStats(
conversationId: string,
query: URLSearchParams
): Promise<AgentGroupMemberStats> {
if (!this.deps.isDatabaseReady()) {
throw new LocalAgentApiError(409, 'DATABASE_NOT_READY', '数据库未就绪,无法查询群员统计')
}
const normalizedConversationId = conversationId.trim()
if (!normalizedConversationId || normalizedConversationId.includes('/') || normalizedConversationId.includes('\\')) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'conversationId 格式无效')
}
const start = this.parseDateFilter(query.get('start'), 'start')
const end = this.parseDateFilter(query.get('end'), 'end')
if (start === undefined || end === undefined) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'start 和 end 必须同时提供')
}
if (start > end) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'start 不能晚于 end')
}
const contacts = await this.deps.listContacts()
const matches = contacts.filter((contact) => contact.m_nsUsrName === normalizedConversationId)
if (matches.length === 0) {
throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到指定会话')
}
if (matches.length > 1) {
throw new LocalAgentApiError(409, 'AMBIGUOUS_CONTACT', 'conversationId 匹配到多个会话')
}
const contact = matches[0]
if (contact.type !== 'group') {
throw new LocalAgentApiError(422, 'NOT_GROUP_CONVERSATION', 'conversationId 不是群会话')
}
if (!contact.m_nsUsrName.endsWith('@chatroom')) {
throw new LocalAgentApiError(422, 'NOT_GROUP_CONVERSATION', 'conversationId 不是有效的群会话 ID')
}
if (!this.deps.getGroupMemberStats) {
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '群员统计能力尚未就绪')
}
const result = await this.deps.getGroupMemberStats({
userMd5: contact.md5,
startTime: start,
endTime: end
})
return this.toAgentGroupMemberStats(contact, result)
}
async listAutomations(filter: {
type?: string | null
enabled?: string | null
}): Promise<unknown[]> {
if (filter.type && !RULE_TYPES.includes(filter.type as AutomationRuleType)) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'type 不受支持')
}
let enabled: boolean | undefined
if (filter.enabled !== undefined && filter.enabled !== null) {
if (filter.enabled !== 'true' && filter.enabled !== 'false') {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'enabled 必须是 true 或 false')
}
enabled = filter.enabled === 'true'
}
const rules = this.deps.automationRuleStore
.listRules()
.filter(
(rule) =>
(!filter.type || rule.ruleType === filter.type) &&
(enabled === undefined || rule.enabled === enabled)
)
return this.toApiRules(rules)
}
async getAutomation(id: string): Promise<unknown> {
const rule = this.deps.automationRuleStore.getRule(id)
if (!rule) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
return (await this.toApiRules([rule]))[0]
}
async validateAutomation(input: unknown): Promise<AgentAutomationValidationResult> {
const structural = validateAutomationDraftShape(input)
if (!structural.valid || !structural.normalized) {
return { valid: false, issues: structural.issues }
}
const issues = [...structural.issues]
const draft = structuredClone(structural.normalized)
const references = await this.resolveDraftReferences(draft, issues)
if (issues.length) return { valid: false, issues }
const config = draft.scheduledReport
const nextRunAt = config ? calculateNextRunAt(config.schedule.time) : null
const effects = this.describeEffects(draft)
return {
valid: true,
issues: [],
normalized: draft,
effects,
nextRunAt,
capabilities: {
databaseReady: this.deps.isDatabaseReady(),
referencedConversationsResolved: references
}
}
}
async createAutomation(input: unknown): Promise<unknown> {
const raw = record(input)
if (raw?.ruleType === 'leave_notification') {
throw new LocalAgentApiError(409, 'SINGLETON_RULE', '退群通知是系统单例规则,请修改现有规则')
}
const validation = await this.validateAutomation(input)
if (!validation.valid || !validation.normalized) {
throw new LocalAgentApiError(
422,
'VALIDATION_FAILED',
'自动化规则校验失败',
validation.issues
)
}
const contacts = await this.deps.listContacts()
const storedDraft = this.toStoreDraft(validation.normalized as AutomationRuleDraft, contacts)
storedDraft.enabled = false
const created = this.deps.automationRuleStore.createRule(storedDraft)
return (await this.toApiRules([created]))[0]
}
async updateAutomation(id: string, patchInput: unknown): Promise<unknown> {
const current = this.deps.automationRuleStore.getRule(id)
if (!current) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
const patch = record(patchInput)
if (!patch) throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体必须是 JSON 对象')
for (const key of Object.keys(patch)) {
if (key === 'ruleType') {
throw new LocalAgentApiError(400, 'RULE_TYPE_IMMUTABLE', 'ruleType 不可修改')
}
if (key === 'id' || key === 'createdAt' || key === 'updatedAt') {
throw new LocalAgentApiError(400, 'IMMUTABLE_FIELD', `${key} 不允许由客户端修改`)
}
if (key === 'enabled') {
throw new LocalAgentApiError(400, 'USE_ENABLE_OPERATION', '请使用 enable 或 disable 操作')
}
if (!UPDATE_FIELDS.has(key)) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `不支持字段:${key}`)
}
}
for (const configKey of ['leaveNotification', 'scheduledReport']) {
if (Object.hasOwn(record(patch[configKey]) || {}, 'targetNeedsReview')) {
throw new LocalAgentApiError(
400,
'IMMUTABLE_FIELD',
`${configKey}.targetNeedsReview 只能通过更新 target 清除`
)
}
}
const apiCurrent = (await this.toApiRules([current]))[0] as AutomationRule & {
requiresReview?: boolean
}
const candidate = deepMergeDraft(toDraft(apiCurrent), patch)
const validation = await this.validateAutomation(candidate)
if (!validation.valid || !validation.normalized) {
throw new LocalAgentApiError(
422,
'VALIDATION_FAILED',
'自动化规则校验失败',
validation.issues
)
}
const contacts = await this.deps.listContacts()
const storedDraft = this.toStoreDraft(validation.normalized as AutomationRuleDraft, contacts)
storedDraft.enabled = current.enabled
const updated = this.deps.automationRuleStore.updateRule(id, storedDraft)
if (!updated) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
return (await this.toApiRules([updated]))[0]
}
async setAutomationEnabled(id: string, enabled: boolean): Promise<unknown> {
const current = this.deps.automationRuleStore.getRule(id)
if (!current) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
if (enabled) {
if (!this.deps.isDatabaseReady()) {
throw new LocalAgentApiError(409, 'VALIDATION_FAILED', '数据库未就绪,无法启用自动化规则', [
{ path: 'database', code: 'database_not_ready', message: 'TraceMemo 数据库未初始化' }
])
}
const apiCurrent = (await this.toApiRules([current]))[0] as AutomationRule
const draft = toDraft(apiCurrent)
const validation = await this.validateAutomation(draft)
if (!validation.valid) {
throw new LocalAgentApiError(
409,
'VALIDATION_FAILED',
'自动化规则校验失败,未启用',
validation.issues
)
}
}
const updated = this.deps.automationRuleStore.setRuleEnabled(id, enabled)
if (!updated) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
return (await this.toApiRules([updated]))[0]
}
async deleteAutomation(id: string): Promise<{ deletedId: string }> {
if (id === BUILTIN_DAILY_REPORT_RULE_ID || id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) {
throw new LocalAgentApiError(409, 'PROTECTED_RULE', '系统内置自动化规则不能删除')
}
const rule = this.deps.automationRuleStore.getRule(id)
if (!rule) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
if (rule.ruleType === 'leave_notification') {
throw new LocalAgentApiError(409, 'PROTECTED_RULE', '退群通知是系统单例规则,不能删除')
}
const deleted = this.deps.automationRuleStore.deleteRule(id)
if (!deleted) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
return { deletedId: id }
}
listExecutions(query: URLSearchParams): {
count: number
executions: AgentAutomationExecution[]
} {
const ruleId = query.get('ruleId') || undefined
const status = query.get('status') || undefined
if (status && !EXECUTION_STATUSES.includes(status as AutomationExecution['status'])) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'status 不受支持')
}
const since = this.parseDateFilter(query.get('since'), 'since')
const until = this.parseDateFilter(query.get('until'), 'until')
if (since !== undefined && until !== undefined && since > until) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'since 不能晚于 until')
}
const rawLimit = query.get('limit')
const limit = rawLimit === null ? 50 : Number(rawLimit)
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'limit 必须是 1 到 200 之间的整数')
}
const rulesById = new Map(
this.deps.automationRuleStore.listRules().map((rule) => [rule.id, rule])
)
const executions = this.deps.automationExecutionLogService
.list({ limit: 200 })
.filter((item) => !ruleId || item.ruleId === ruleId)
.filter((item) => !status || item.status === status)
.filter((item) => since === undefined || item.triggerTime >= since)
.filter((item) => until === undefined || item.triggerTime <= until)
.slice(0, limit)
.map((item) => this.toApiExecution(item, rulesById.get(item.ruleId)?.ruleType))
return { count: executions.length, executions }
}
private toGroupExitMonitorState(state: GroupExitMonitorStateSource): AgentGroupExitMonitorState {
const eventCount = Number.isFinite(state.totalEventCount)
? Number(state.totalEventCount)
: state.events?.length || 0
return {
enabled: state.enabled === true,
running: state.running === true,
nativeMonitorActive: state.nativeMonitorActive === true,
monitoredConversationIds: [...(state.monitoredRoomIds || [])],
monitoredGroupCount: Number.isFinite(state.monitoredGroupCount)
? Number(state.monitoredGroupCount)
: state.monitoredRoomIds?.length || 0,
monitorSelectionConfigured: state.monitorSelectionConfigured !== false,
lastCheckedAt: toIsoTimestamp(state.lastCheckedAt),
lastReadAt: toIsoTimestamp(state.lastReadAt),
eventCount,
unreadCount: Number.isFinite(state.unreadCount) ? Number(state.unreadCount) : 0
}
}
private toAgentGroupExitEvent(event: GroupExitMonitorEvent): AgentGroupExitMonitorEvent {
return {
eventId: event.id,
conversationId: event.roomId,
groupName: event.groupName,
memberId: event.memberWxid,
memberName: event.memberName,
wechatName: event.wechatName || '',
groupRemark: event.groupRemark || '',
contactRemark: event.contactRemark || '',
previousCount: event.previousCount,
currentCount: event.currentCount,
delta: event.delta,
message: event.message,
detectedAt: new Date(event.detectedAt).toISOString()
}
}
private toAgentGroupMemberStats(contact: Contact, result: GroupMemberStatsResult): AgentGroupMemberStats {
const conversationName =
contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName
return {
conversation: { id: contact.m_nsUsrName, name: conversationName },
conversationId: contact.m_nsUsrName,
range: {
start: new Date(result.startTime).toISOString(),
end: new Date(result.endTime).toISOString()
},
memberCount: result.memberCount,
activeMemberCount: result.activeMemberCount,
silentMemberCount: result.silentMemberCount,
activeMembers: result.activeMembers.map((member) => ({
memberId: member.senderId,
displayName: member.displayName,
groupNickname: member.groupNickname,
messageCount: member.messageCount,
lastMessageAt: toIsoTimestamp(member.lastMessageTime)
})),
silentMembers: result.silentMembers.map((member) => ({
memberId: member.senderId,
displayName: member.displayName,
groupNickname: member.groupNickname
})),
freshness: result.freshness,
complete: result.complete,
limitations: [...result.limitations],
unattributedMessages: result.unattributedMessages,
excludedSystemMessages: result.excludedSystemMessages,
firstMessageAt:
toIsoTimestamp(result.firstMessageTime ?? undefined)
}
}
private parseDateFilter(value: string | null, name: string): number | undefined {
if (value === null) return undefined
if (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `${name} 必须是带时区的 ISO-8601 时间`)
}
const timestamp = Date.parse(value)
if (!Number.isFinite(timestamp)) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `${name} 不是有效时间`)
}
return timestamp
}
private toApiExecution(
execution: AutomationExecution,
ruleType?: AutomationRuleType
): AgentAutomationExecution {
return {
executionId: execution.executionId,
ruleId: execution.ruleId,
ruleName: execution.ruleName,
...(ruleType ? { ruleType } : {}),
...(execution.trigger ? { trigger: execution.trigger } : {}),
status: execution.status,
triggerTime: execution.triggerTime,
durationMs: execution.durationMs,
sourceDisplayName: execution.sourceDisplayName,
startedAt: new Date(execution.triggerTime).toISOString(),
finishedAt:
execution.status === 'running'
? null
: new Date(execution.triggerTime + execution.durationMs).toISOString(),
...(execution.errorSummary ? { error: execution.errorSummary } : {})
}
}
private async resolveDraftReferences(
draft: AutomationRuleDraft,
issues: AgentAutomationValidationIssue[]
): Promise<boolean> {
const needsContacts =
(draft.ruleType === 'daily_report' && draft.conditions.conversationIds.length > 0) ||
draft.ruleType === 'scheduled_report' ||
(draft.ruleType === 'leave_notification' &&
(draft.leaveNotification?.target.type === 'contact' ||
(draft.leaveNotification?.notifyRoomIds.length ?? 0) > 0))
if (!needsContacts) return true
if (!this.deps.isDatabaseReady()) {
issues.push({
path: 'database',
code: 'database_not_ready',
message: 'TraceMemo 数据库未初始化,无法解析联系人或群聊 ID'
})
return false
}
const contacts = await this.deps.listContacts()
let resolved = true
const resolve = (query: string, scope: RuleScope, path: string): Contact | undefined => {
const direct = contacts.filter((contact) => {
const identifiers = [
contact.md5,
contact.m_nsUsrName,
contact.wxid,
contact.wechatId,
contact.alias
]
return identifiers.some(
(identifier) => identifier?.toLocaleLowerCase() === query.toLocaleLowerCase()
)
})
const scopedDirect = direct.filter(
(contact) =>
scope === 'any' ||
(scope === 'person' ? contact.type === 'user' : contact.type === 'group')
)
if (scopedDirect.length === 1) return scopedDirect[0]
const result = resolveContact(query, contacts, scope)
if (result.matched && result.conversationId) {
return contacts.find((contact) => contact.md5 === result.conversationId)
}
resolved = false
issues.push({
path,
code: result.ambiguous ? 'ambiguous_contact' : 'contact_not_found',
message: result.ambiguous
? '标识匹配到多个联系人,请使用稳定 ID'
: '无法解析到现有联系人或群聊',
...(result.ambiguous
? {
details: {
candidates: result.candidates.map((candidate) => {
const contact = contacts.find((item) => item.md5 === candidate.conversationId)
return contact
? { id: stableId(contact), name: candidate.displayName }
: { id: candidate.conversationId, name: candidate.displayName }
})
}
}
: {})
})
return undefined
}
if (draft.ruleType === 'daily_report') {
const scope: RuleScope =
draft.scope === 'group' ? 'group' : draft.scope === 'direct' ? 'person' : 'any'
const ids: string[] = []
for (const [index, query] of draft.conditions.conversationIds.entries()) {
const contact = resolve(query, scope, `conditions.conversationIds[${index}]`)
if (contact) ids.push(stableId(contact))
}
draft.conditions.conversationIds = ids
}
if (draft.ruleType === 'scheduled_report' && draft.scheduledReport) {
const source = resolve(
draft.scheduledReport.report.sourceConversationId,
'group',
'scheduledReport.report.sourceConversationId'
)
if (source) draft.scheduledReport.report.sourceConversationId = source.m_nsUsrName
if (draft.scheduledReport.target.type === 'contact') {
const target = resolve(
draft.scheduledReport.target.contactId || '',
'person',
'scheduledReport.target.contactId'
)
if (target) draft.scheduledReport.target.contactId = stableId(target)
}
}
if (draft.ruleType === 'leave_notification' && draft.leaveNotification) {
if (draft.leaveNotification.target.type === 'contact') {
const target = resolve(
draft.leaveNotification.target.contactId || '',
'person',
'leaveNotification.target.contactId'
)
if (target) draft.leaveNotification.target.contactId = stableId(target)
}
const ids: string[] = []
for (const [index, query] of draft.leaveNotification.notifyRoomIds.entries()) {
const group = resolve(query, 'group', `leaveNotification.notifyRoomIds[${index}]`)
if (group) ids.push(group.m_nsUsrName)
}
draft.leaveNotification.notifyRoomIds = ids
}
return resolved
}
private toStoreDraft(draft: AutomationRuleDraft, contacts: Contact[]): AutomationRuleDraft {
const stored = structuredClone(draft)
if (stored.ruleType === 'daily_report') {
stored.conditions.conversationIds = stored.conditions.conversationIds.map(
(id) => contacts.find((contact) => stableId(contact) === id)?.md5 || id
)
}
return stored
}
private async toApiRules(rules: AutomationRule[]): Promise<unknown[]> {
const contacts = await this.deps.listContacts().catch(() => [])
return rules.map((rule) => {
const copy = structuredClone(rule) as AutomationRule & { requiresReview?: boolean }
if (copy.ruleType === 'daily_report') {
copy.conditions.conversationIds = copy.conditions.conversationIds.map((id) =>
contacts.find((contact) => contact.md5 === id)
? stableId(contacts.find((contact) => contact.md5 === id)!)
: id
)
}
if (copy.ruleType === 'scheduled_report' && copy.scheduledReport) {
copy.requiresReview = copy.scheduledReport.targetNeedsReview === true
delete copy.scheduledReport.legacyTarget
delete copy.scheduledReport.legacySourceGroup
delete copy.scheduledReport.lastRunAt
delete copy.scheduledReport.lastScheduledSlot
}
if (copy.ruleType === 'leave_notification' && copy.leaveNotification) {
copy.requiresReview = copy.leaveNotification.targetNeedsReview === true
}
return copy
})
}
private describeEffects(draft: AutomationRuleDraft): Record<string, unknown> {
if (draft.ruleType === 'scheduled_report' && draft.scheduledReport) {
const config = draft.scheduledReport
return {
generatesReport: true,
sendsWechatMessage: true,
sourceConversationId: config.report.sourceConversationId,
target: config.target
}
}
if (draft.ruleType === 'leave_notification' && draft.leaveNotification) {
return {
sendsWechatMessage: true,
monitorScope: 'configured_in_group_exit_monitor',
notificationScope: draft.leaveNotification.notifyScope,
notificationRoomIds:
draft.leaveNotification.notifyScope === 'selected'
? draft.leaveNotification.notifyRoomIds
: undefined,
target: draft.leaveNotification.target
}
}
return {
actions: draft.actions.filter((action) => action.enabled).map((action) => action.type),
sendsWechatMessage: draft.actions.some(
(action) => action.enabled && action.type === 'sendReportImage'
)
}
}
}
+27 -12
View File
@@ -46,6 +46,21 @@ function parseBody(bodyText: string, contentType?: string): { json?: unknown; bo
return { bodyText }
}
function materializeEndpointPath(pathname: string, query: Record<string, string>): string {
return pathname.replace('{conversationId}', encodeURIComponent(query.conversationId || ''))
}
function appendQueryParameters(
url: URL,
endpointPath: string,
entries: Array<[string, string]>
): void {
for (const [key, value] of entries) {
if (endpointPath.includes(`{${key}}`)) continue
if (value.trim()) url.searchParams.set(key, value.trim())
}
}
export function buildLocalApiCurlCommand(payload: unknown): {
success: boolean
command?: string
@@ -71,18 +86,17 @@ export function buildLocalApiCurlCommand(payload: unknown): {
const service = apiServer.getState()
const targetHost = requestHost(service.host)
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
const url = new URL(endpoint.path, `http://${hostPart}:${service.port}`)
entries.forEach(([key, value]) => {
if (value.trim()) url.searchParams.set(key, value.trim())
})
const endpointPath = materializeEndpointPath(endpoint.path, query as Record<string, string>)
const url = new URL(endpointPath, `http://${hostPart}:${service.port}`)
appendQueryParameters(url, endpoint.path, entries)
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
if (endpointId !== 'health' && !token) {
return { success: false, error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态' }
}
const authHeader = token ? ` -H 'Authorization: Bearer ${token}'` : ''
const command =
endpoint.method === 'POST'
? `curl -X POST '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
endpoint.method === 'POST' || endpoint.method === 'PATCH'
? `curl -X ${endpoint.method} '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
: `curl '${url.toString()}'${authHeader}`
return { success: true, command }
}
@@ -110,10 +124,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
const targetHost = requestHost(service.host)
const targetPort = service.port
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
const url = new URL(endpoint.path, `http://${hostPart}:${targetPort}`)
entries.forEach(([key, value]) => {
if (value.trim()) url.searchParams.set(key, value.trim())
})
const endpointPath = materializeEndpointPath(endpoint.path, query as Record<string, string>)
const url = new URL(endpointPath, `http://${hostPart}:${targetPort}`)
appendQueryParameters(url, endpoint.path, entries)
if (!service.running) {
return {
@@ -150,7 +163,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
})
}
const headers: Record<string, string> = {}
if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json'
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') {
headers['Content-Type'] = 'application/json'
}
if (token) headers.Authorization = `Bearer ${token}`
const request = http.request(
url,
@@ -208,7 +223,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
error: error.message
})
})
if (endpoint.method === 'POST') request.write(body)
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') request.write(body)
request.end()
})
}
+51
View File
@@ -0,0 +1,51 @@
import { readFileSync } from 'fs'
export interface TrayNativeImage {
addRepresentation(options: { scaleFactor: number; dataURL: string }): void
isEmpty(): boolean
resize(options: { width: number; height: number; quality: 'best' }): TrayNativeImage
setTemplateImage(option: boolean): void
}
export interface TrayNativeImageFactory<T extends TrayNativeImage = TrayNativeImage> {
createEmpty(): T
createFromPath(path: string): T
}
export interface TrayTemplateIconPaths {
oneX: string
twoX: string
}
type ReadImageFile = (path: string) => Buffer
function toPngDataUrl(buffer: Buffer): string {
return `data:image/png;base64,${buffer.toString('base64')}`
}
export function createTrayImage<T extends TrayNativeImage>(
platform: NodeJS.Platform,
appIconPath: string,
templateIconPaths: TrayTemplateIconPaths,
nativeImage: TrayNativeImageFactory<T>,
readImageFile: ReadImageFile = readFileSync
): T {
if (platform === 'darwin') {
const image = nativeImage.createEmpty()
image.addRepresentation({
scaleFactor: 1,
dataURL: toPngDataUrl(readImageFile(templateIconPaths.oneX))
})
image.addRepresentation({
scaleFactor: 2,
dataURL: toPngDataUrl(readImageFile(templateIconPaths.twoX))
})
image.setTemplateImage(true)
return image
}
const image = nativeImage.createFromPath(appIconPath)
return image.isEmpty()
? nativeImage.createEmpty()
: (image.resize({ width: 24, height: 24, quality: 'best' }) as T)
}
-1
View File
@@ -1951,7 +1951,6 @@ function App(): React.ReactElement {
contentFilter={contentFilter}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
onRefreshData={loadContacts}
onReloadAvatars={handleReloadCurrentAvatars}
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
@@ -18,7 +18,6 @@ interface ChatWindowProps {
contentFilter?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onReloadAvatars?: () => Promise<void>
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
@@ -40,7 +39,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
contentFilter,
onContentFilterChange,
onRefresh,
onRefreshData,
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
@@ -182,7 +180,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
isAiLoading={isAiLoading}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
onTestSend={() => void handleOpenPersonalWechatSend()}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
@@ -26,11 +26,7 @@ export function AccountSummary({
const displayName =
showAccount && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = showAccount && selfInfo ? selfInfo.wxid : '打开设置'
const statusText = dbReady
? '数据库已连接'
: dbConnecting
? '正在连接数据库'
: '数据库未连接'
const statusText = dbReady ? '数据库已连接' : dbConnecting ? '正在连接数据库' : '数据库未连接'
const statusClass = dbReady ? 'ready' : dbConnecting ? 'connecting' : ''
const initial = (displayName || '?').charAt(0)
const title = `${displayName}\n${subtitle}`
@@ -66,8 +62,8 @@ export function AccountSummary({
</span>
<button type="button" className="account-summary-settings" onClick={onClick} title="设置">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />
<path d="M19.4 15a8.2 8.2 0 0 0 .1-1l2-1.5-2-3.5-2.4 1a7.5 7.5 0 0 0-1.7-1l-.3-2.5h-4l-.4 2.5a7.5 7.5 0 0 0-1.7 1l-2.3-1-2 3.5 2 1.5a8.2 8.2 0 0 0 0 2l-2 1.5 2 3.5 2.3-1a7.5 7.5 0 0 0 1.7 1l.4 2.5h4l.3-2.5a7.5 7.5 0 0 0 1.7-1l2.4 1 2-3.5-2.1-1.5Z" />
<path d="M12.22 2h-.44a2 2 0 0 0-2 1.72l-.15 1.58a8 8 0 0 0-1.77 1.03l-1.48-.6a2 2 0 0 0-2.5.88l-.22.39a2 2 0 0 0 .51 2.68l1.25.98a8 8 0 0 0 0 2.08l-1.25.98a2 2 0 0 0-.51 2.68l.22.39a2 2 0 0 0 2.5.88l1.48-.6c.54.43 1.14.78 1.77 1.03l.15 1.58a2 2 0 0 0 2 1.72h.44a2 2 0 0 0 2-1.72l.15-1.58c.63-.25 1.23-.6 1.77-1.03l1.48.6a2 2 0 0 0 2.5-.88l.22-.39a2 2 0 0 0-.51-2.68l-1.25-.98a8 8 0 0 0 0-2.08l1.25-.98a2 2 0 0 0 .51-2.68l-.22-.39a2 2 0 0 0-2.5-.88l-1.48.6a8 8 0 0 0-1.77-1.03l-.15-1.58a2 2 0 0 0-2-1.72Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
@@ -1,18 +1,8 @@
import React, { useState } from 'react'
import { Contact } from '../../../../shared/types'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Tooltip,
TooltipContent,
TooltipTrigger
} from '../ui'
import { Button, IconButton, Tooltip, TooltipContent, TooltipTrigger } from '../ui'
import { ConversationContentSearch } from './ConversationContentSearch'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
import { AiIcon, RefreshIcon, SearchIcon, SendIcon, StatsIcon } from './icons'
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
import { GroupMemberStatsDialog } from '../group-stats/GroupMemberStatsDialog'
@@ -25,7 +15,6 @@ interface ChatHeaderProps {
isAiLoading: boolean
onContentFilterChange: (value: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onTestSend: () => void
onOpenAiSettings: () => void
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
@@ -41,7 +30,6 @@ export function ChatHeader({
isAiLoading,
onContentFilterChange,
onRefresh,
onRefreshData,
onTestSend,
onOpenAiSettings,
onOpenLocalIndexSettings
@@ -96,27 +84,20 @@ export function ChatHeader({
<IconButton label="刷新聊天记录" variant="ghost" className="h-8 w-8" onClick={onRefresh}>
<RefreshIcon />
</IconButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
{isGroupChat ? (
<Button
variant="outline"
size="sm"
className="chat-header-text-action"
aria-label="更多功能"
title="更多功能"
aria-label="群发言统计"
title="群发言统计"
onClick={() => setStatsOpen(true)}
>
<MoreIcon />
<span>更多功能</span>
<StatsIcon />
<span>群发言统计</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onRefreshData?.()}>刷新数据</DropdownMenuItem>
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
{isGroupChat ? (
<DropdownMenuItem onSelect={() => setStatsOpen(true)}>群发言统计</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{supportsPersonalWechatSend ? (
<Button
variant="outline"
+4 -4
View File
@@ -34,12 +34,12 @@ export function ExportIcon({ className }: IconProps): React.ReactElement {
)
}
export function MoreIcon({ className }: IconProps): React.ReactElement {
export function StatsIcon({ className }: IconProps): React.ReactElement {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
<path d="M5.5 19v-6" />
<path d="M12 19V5" />
<path d="M18.5 19v-9" />
</svg>
)
}
@@ -27,6 +27,7 @@ import {
buildContactSearchIndex,
filterContactSearchIndex
} from '../../../../shared/contact-search'
import { loadExportPreferences, saveExportPreferences } from './exportPreferences'
const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user']
const contactTypeKey = (types: ExportContactType[] | undefined): string =>
@@ -44,6 +45,8 @@ export function ExportWorkspace({
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const defaultNameMode = initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
const [savedPreferences] = useState(() => loadExportPreferences(defaultNameMode))
const runningAllTask = exportTasks.find(
(task) => task.scope === 'all' && task.status === 'running'
)
@@ -64,24 +67,30 @@ export function ExportWorkspace({
const contactSearchIndex = useMemo(() => buildContactSearchIndex(contacts), [contacts])
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>(() => (runningAllTask ? 'all' : 'today'))
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>(
initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
const [range, setRange] = useState<ExportRange>(() => savedPreferences.range)
const [startDate, setStartDate] = useState(() => savedPreferences.startDate)
const [endDate, setEndDate] = useState(() => savedPreferences.endDate)
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(
() => new Set(savedPreferences.selectedKinds)
)
const [nameMode, setNameMode] = useState<ExportNameMode>(() => savedPreferences.nameMode)
const [includeMedia, setIncludeMedia] = useState(() => savedPreferences.includeMedia)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(
() => savedPreferences.includeVoiceTranscripts
)
const [includeMedia, setIncludeMedia] = useState(true)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(true)
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
const [fileName, setFileName] = useState('')
const [outputDirectory, setOutputDirectory] = useState('')
const [includeAvatars, setIncludeAvatars] = useState(() => savedPreferences.includeAvatars)
const [preferOriginal, setPreferOriginal] = useState(() => savedPreferences.preferOriginal)
const [fallbackThumbnail, setFallbackThumbnail] = useState(
() => savedPreferences.fallbackThumbnail
)
const [keepMissing, setKeepMissing] = useState(() => savedPreferences.keepMissing)
const [format, setFormat] = useState<ExportFormat>(
() => runningAllTask?.format || savedPreferences.format
)
const [zip, setZip] = useState(() => runningAllTask?.zip ?? savedPreferences.zip)
const [fileName, setFileName] = useState(() => savedPreferences.fileName)
const [outputDirectory, setOutputDirectory] = useState(() => savedPreferences.outputDirectory)
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
@@ -92,6 +101,42 @@ export function ExportWorkspace({
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5
React.useEffect(() => {
saveExportPreferences({
format,
range,
startDate,
endDate,
selectedKinds: Array.from(selectedKinds) as ExportRequest['kinds'],
nameMode,
includeMedia,
includeVoiceTranscripts,
includeAvatars,
preferOriginal,
fallbackThumbnail,
keepMissing,
zip,
fileName,
outputDirectory
})
}, [
endDate,
fallbackThumbnail,
fileName,
format,
includeAvatars,
includeMedia,
includeVoiceTranscripts,
keepMissing,
nameMode,
outputDirectory,
preferOriginal,
range,
selectedKinds,
startDate,
zip
])
React.useEffect(() => {
if (selectedContacts.length > 0) return
const candidate = initialContact || contacts[0]
@@ -206,7 +251,6 @@ export function ExportWorkspace({
const handleSelectContact = (contact: Contact): void => {
if (exportAll) {
setExportAll(false)
setRange('today')
}
if (!selectionMode) {
setSelectedContacts([contact])
@@ -236,7 +280,6 @@ export function ExportWorkspace({
setExportAll(true)
setAllContactTypes([...ALL_CONTACT_TYPES])
setSelectionMode(false)
setRange('all')
setStatus('idle')
}
@@ -417,6 +460,7 @@ export function ExportWorkspace({
setSelectedKinds(new Set(['text']))
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
setIncludeMedia(true)
setIncludeVoiceTranscripts(true)
setIncludeAvatars(true)
setPreferOriginal(true)
setFallbackThumbnail(true)
@@ -424,6 +468,7 @@ export function ExportWorkspace({
setFormat('csv')
setZip(false)
setFileName('')
setOutputDirectory('')
setStatus('idle')
setJobId('')
setProgress(null)
@@ -492,7 +537,7 @@ export function ExportWorkspace({
selectionMode={selectionMode}
exportContactCount={exportContacts.length}
format={format}
range={range}
range={exportAll ? 'all' : range}
startDate={startDate}
endDate={endDate}
selectedKinds={selectedKinds}
@@ -0,0 +1,115 @@
import type { ExportFormat, ExportMessageKind, ExportNameMode } from '../../../../shared/export'
import type { ExportRange } from './exportTypes'
export interface ExportPreferences {
format: ExportFormat
range: ExportRange
startDate: string
endDate: string
selectedKinds: ExportMessageKind[]
nameMode: ExportNameMode
includeMedia: boolean
includeVoiceTranscripts: boolean
includeAvatars: boolean
preferOriginal: boolean
fallbackThumbnail: boolean
keepMissing: boolean
zip: boolean
fileName: string
outputDirectory: string
}
export const EXPORT_PREFERENCES_STORAGE_KEY = 'tracememo_export_preferences'
const formats: ExportFormat[] = ['html', 'csv', 'json', 'markdown']
const ranges: ExportRange[] = ['all', 'today', 'threeDays', 'sevenDays', 'custom']
const messageKinds: ExportMessageKind[] = [
'text',
'image',
'video',
'voice',
'sticker',
'file',
'share',
'location',
'system'
]
const nameModes: ExportNameMode[] = ['groupNickname', 'remark', 'wechatNickname']
export function loadExportPreferences(defaultNameMode: ExportNameMode): ExportPreferences {
const defaults: ExportPreferences = {
format: 'csv',
range: 'today',
startDate: '',
endDate: '',
selectedKinds: ['text'],
nameMode: defaultNameMode,
includeMedia: true,
includeVoiceTranscripts: true,
includeAvatars: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true,
zip: false,
fileName: '',
outputDirectory: ''
}
try {
const stored = window.localStorage.getItem(EXPORT_PREFERENCES_STORAGE_KEY)
if (!stored) return defaults
const value = JSON.parse(stored) as Partial<ExportPreferences>
return {
format: formats.includes(value.format as ExportFormat)
? (value.format as ExportFormat)
: defaults.format,
range: ranges.includes(value.range as ExportRange)
? (value.range as ExportRange)
: defaults.range,
startDate: typeof value.startDate === 'string' ? value.startDate : defaults.startDate,
endDate: typeof value.endDate === 'string' ? value.endDate : defaults.endDate,
selectedKinds: Array.isArray(value.selectedKinds)
? [
...new Set(
value.selectedKinds.filter((kind): kind is ExportMessageKind =>
messageKinds.includes(kind as ExportMessageKind)
)
)
]
: defaults.selectedKinds,
nameMode: nameModes.includes(value.nameMode as ExportNameMode)
? (value.nameMode as ExportNameMode)
: defaults.nameMode,
includeMedia:
typeof value.includeMedia === 'boolean' ? value.includeMedia : defaults.includeMedia,
includeVoiceTranscripts:
typeof value.includeVoiceTranscripts === 'boolean'
? value.includeVoiceTranscripts
: defaults.includeVoiceTranscripts,
includeAvatars:
typeof value.includeAvatars === 'boolean' ? value.includeAvatars : defaults.includeAvatars,
preferOriginal:
typeof value.preferOriginal === 'boolean' ? value.preferOriginal : defaults.preferOriginal,
fallbackThumbnail:
typeof value.fallbackThumbnail === 'boolean'
? value.fallbackThumbnail
: defaults.fallbackThumbnail,
keepMissing:
typeof value.keepMissing === 'boolean' ? value.keepMissing : defaults.keepMissing,
zip: typeof value.zip === 'boolean' ? value.zip : defaults.zip,
fileName: typeof value.fileName === 'string' ? value.fileName : defaults.fileName,
outputDirectory:
typeof value.outputDirectory === 'string' ? value.outputDirectory : defaults.outputDirectory
}
} catch {
return defaults
}
}
export function saveExportPreferences(preferences: ExportPreferences): void {
try {
window.localStorage.setItem(EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))
} catch {
// Export remains usable when browser storage is unavailable.
}
}
@@ -214,11 +214,11 @@ export function AgentHubWorkspace({
<ul>
<li>
<i />
本机 HTTP 通信
在主进程内运行,不开放本地端口
</li>
<li>
<i />
入站请求鉴权
对话记录可回看
</li>
<li>
<i />
@@ -58,6 +58,67 @@ export const API_ENDPOINTS: ApiEndpoint[] = [
{ key: 'q', label: '待解析标识', required: true, placeholder: '昵称、wxid 或 md5' }
]
}),
endpoint('app-capabilities', {
name: '应用能力',
description: '查看数据库、Query、Automation 与运行环境的能力状态。'
}),
endpoint('group-exit-monitor', {
name: '退群监控状态',
description: '查看监控开关、监控群范围、事件数量和运行状态。'
}),
endpoint('group-exit-monitor-update', {
name: '配置退群监控',
description: '设置监控群范围或启用/关闭退群监控,不会修改退群通知 Automation。',
body: true
}),
endpoint('group-exit-events', {
name: '退群事件历史',
description: '按群和时间窗口查询退群事件,最多返回 200 条。',
parameters: [
{ key: 'conversationId', label: '群会话 ID', placeholder: 'xxx@chatroom' },
{ key: 'since', label: '开始时间', placeholder: '2026-10-01T00:00:00+07:00' },
{ key: 'until', label: '结束时间', placeholder: '2026-10-02T23:59:59+07:00' },
{ key: 'limit', label: '数量上限', placeholder: '50,最大 200' }
]
}),
endpoint('group-member-stats', {
name: '群成员活跃统计',
description: '查询指定群在时间窗口内的活跃成员、沉默成员和数据完整性。',
parameters: [
{ key: 'conversationId', label: '群会话 ID', required: true, placeholder: 'xxx@chatroom' },
{ key: 'start', label: '开始时间', required: true, placeholder: '2026-10-01T00:00:00+07:00' },
{ key: 'end', label: '结束时间', required: true, placeholder: '2026-10-02T23:59:59+07:00' }
]
}),
endpoint('automations', {
name: '自动化规则',
description: '列出自动化规则,可按类型和启用状态筛选。',
parameters: [
{ key: 'type', label: '规则类型', placeholder: 'daily_report / scheduled_report / leave_notification' },
{ key: 'enabled', label: '启用状态', placeholder: 'true 或 false' }
]
}),
endpoint('automation-create', {
name: '创建自动化规则',
description: '创建一条默认停用的自动化规则。',
body: true
}),
endpoint('automation-validate', {
name: '校验自动化规则',
description: '检查规则字段、目标标识和预计影响,不保存也不执行。',
body: true
}),
endpoint('automation-executions', {
name: '自动化执行记录',
description: '分页上限内查询执行结果,可按规则、状态和时间筛选。',
parameters: [
{ key: 'ruleId', label: '规则 ID', placeholder: 'ruleId' },
{ key: 'status', label: '状态', placeholder: 'running / success / failed' },
{ key: 'since', label: '开始时间', placeholder: '2026-10-01T00:00:00+07:00' },
{ key: 'until', label: '结束时间', placeholder: '2026-10-02T23:59:59+07:00' },
{ key: 'limit', label: '数量上限', placeholder: '50,最大 200' }
]
}),
endpoint('report', {
name: '群聊日报导出',
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
@@ -1,4 +1,4 @@
export type ApiMethod = 'GET' | 'POST'
export type ApiMethod = 'GET' | 'POST' | 'PATCH'
export type { ApiTokenStatus } from '../../../../../shared/local-api-auth'
export interface ApiParameter {
@@ -4,8 +4,19 @@ export function buildApiUrl(
path: string,
params: Record<string, string>
): string {
const url = new URL(path, `http://${host}:${port}`)
let resolvedPath = path
const pathParameterKeys = new Set(
Object.keys(params).filter((key) => path.includes(`{${key}}`))
)
Object.entries(params).forEach(([key, value]) => {
const normalized = value.trim()
if (normalized && resolvedPath.includes(`{${key}}`)) {
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(normalized))
}
})
const url = new URL(resolvedPath, `http://${host}:${port}`)
Object.entries(params).forEach(([key, value]) => {
if (pathParameterKeys.has(key)) return
if (value.trim()) url.searchParams.set(key, value.trim())
})
return url.toString()
@@ -4,11 +4,36 @@ function DiagnosticIcon({ status }: { status: DiagnosticStatus }): React.ReactEl
if (status === 'checking') {
return <span className="settings-diagnostic-icon checking" aria-label="检测中" />
}
const symbol =
status === 'success' ? '✓' : status === 'warning' ? '!' : status === 'error' ? '×' : '—'
const icon =
status === 'success' ? (
<path d="m5 12.5 4.5 4.5L19 7" />
) : status === 'warning' ? (
<>
<path d="M12 6v7" />
<circle cx="12" cy="17" r="1.5" fill="currentColor" stroke="none" />
</>
) : status === 'error' ? (
<path d="m7 7 10 10M17 7 7 17" />
) : (
<path d="M6 12h12" />
)
return (
<span className={`settings-diagnostic-icon ${status}`} aria-hidden="true">
{symbol}
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
>
{icon}
</svg>
</span>
)
}
+3 -2
View File
@@ -416,8 +416,9 @@
border: 1px solid #929a96;
border-radius: 50%;
color: #929a96;
font-size: 11px;
font-weight: 700;
}
.settings-diagnostic-icon svg {
display: block;
}
.settings-diagnostic-icon.success {
border-color: #2e8b68;
@@ -0,0 +1,445 @@
import {
AUTOMATION_REPLY_DELAY_MAX_SECONDS,
SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS,
SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS,
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
normalizeRuleDraft,
type AutomationRuleDraft,
type AutomationRuleType
} from '../automation'
import { validateGroupExitNotificationTemplate } from '../group-exit-monitor'
import { isSelectableReportTemplateId } from '../report-templates'
import {
type ScheduledReportMemberNameMode,
type ScheduledReportMessageType,
type ScheduledReportRange
} from '../scheduled-report'
import type { AgentAutomationValidationIssue } from './contracts'
type JsonRecord = Record<string, unknown>
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'scheduled_report', 'leave_notification']
const SCOPES = ['group', 'direct', 'all'] as const
const KEYWORD_MODES = ['contains', 'exact', 'prefix'] as const
const ACTION_TYPES = ['replyText', 'generateReport', 'sendReportImage'] as const
const SCHEDULE_RANGES: ScheduledReportRange[] = ['today', 'yesterday', '7days', 'recent24h']
const MESSAGE_TYPES: ScheduledReportMessageType[] = [
'text',
'image',
'sticker',
'video',
'voice',
'share',
'system'
]
const MEMBER_NAME_MODES: ScheduledReportMemberNameMode[] = [
'groupNickname',
'wechatNickname',
'remark'
]
const TARGET_TYPES = ['source_chat', 'self', 'file_transfer', 'contact'] as const
function asRecord(value: unknown): JsonRecord | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: undefined
}
function addIssue(
issues: AgentAutomationValidationIssue[],
path: string,
code: string,
message: string
): void {
issues.push({ path, code, message })
}
function checkObject(
value: unknown,
path: string,
allowedKeys: readonly string[],
issues: AgentAutomationValidationIssue[]
): JsonRecord | undefined {
const record = asRecord(value)
if (!record) {
addIssue(issues, path, 'invalid_type', '必须是 JSON 对象')
return undefined
}
for (const key of Object.keys(record)) {
if (!allowedKeys.includes(key)) {
addIssue(issues, `${path}.${key}`, 'unknown_field', '不支持此字段')
}
}
return record
}
function checkString(
value: unknown,
path: string,
issues: AgentAutomationValidationIssue[],
options: { required?: boolean; max?: number; allowEmpty?: boolean } = {}
): value is string {
if (typeof value !== 'string') {
if (options.required || value !== undefined) {
addIssue(issues, path, 'invalid_type', '必须是字符串')
}
return false
}
const trimmed = value.trim()
if (options.required && !trimmed) addIssue(issues, path, 'required', '不能为空')
if (!options.allowEmpty && !options.required && value.length > 0 && !trimmed) {
addIssue(issues, path, 'invalid_value', '不能只包含空白字符')
}
if (options.max !== undefined && value.length > options.max) {
addIssue(issues, path, 'too_long', `最多 ${options.max} 个字符`)
}
return true
}
function checkBoolean(
value: unknown,
path: string,
issues: AgentAutomationValidationIssue[],
required = false
): void {
if (value === undefined && !required) return
if (typeof value !== 'boolean') addIssue(issues, path, 'invalid_type', '必须是布尔值')
}
function checkInteger(
value: unknown,
path: string,
issues: AgentAutomationValidationIssue[],
min: number,
max: number,
required = false
): void {
if (value === undefined && !required) return
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max) {
addIssue(issues, path, 'out_of_range', `必须是 ${min} 到 ${max} 之间的整数`)
}
}
function checkEnum(
value: unknown,
path: string,
values: readonly string[],
issues: AgentAutomationValidationIssue[],
required = false
): void {
if (value === undefined && !required) return
if (typeof value !== 'string' || !values.includes(value)) {
addIssue(issues, path, 'invalid_enum', `仅支持:${values.join(', ')}`)
}
}
function validateDailyReport(raw: JsonRecord, issues: AgentAutomationValidationIssue[]): void {
const conditions = checkObject(
raw.conditions,
'conditions',
['requireMentionMe', 'keyword', 'keywordMatchMode', 'conversationIds', 'ignoreSelf'],
issues
)
if (conditions) {
checkBoolean(conditions.requireMentionMe, 'conditions.requireMentionMe', issues, true)
checkBoolean(conditions.ignoreSelf, 'conditions.ignoreSelf', issues, true)
if (conditions.keyword === undefined) {
addIssue(issues, 'conditions.keyword', 'required', '必须是字符串')
} else {
checkString(conditions.keyword, 'conditions.keyword', issues, { max: 200, allowEmpty: true })
}
checkEnum(
conditions.keywordMatchMode,
'conditions.keywordMatchMode',
KEYWORD_MODES,
issues,
true
)
if (!Array.isArray(conditions.conversationIds)) {
addIssue(issues, 'conditions.conversationIds', 'invalid_type', '必须是 ID 数组')
} else {
if (conditions.conversationIds.length > 100) {
addIssue(issues, 'conditions.conversationIds', 'too_many_items', '最多支持 100 个会话')
}
conditions.conversationIds.forEach((id, index) =>
checkString(id, `conditions.conversationIds[${index}]`, issues, {
required: true,
max: 300
})
)
}
}
if (!Array.isArray(raw.actions)) {
addIssue(issues, 'actions', 'invalid_type', '必须是动作数组')
return
}
if (raw.actions.length > 3) addIssue(issues, 'actions', 'too_many_items', '最多支持 3 个动作')
const seen = new Set<string>()
raw.actions.forEach((actionValue, index) => {
const path = `actions[${index}]`
const action = checkObject(actionValue, path, ['type', 'enabled', 'text'], issues)
if (!action) return
checkEnum(action.type, `${path}.type`, ACTION_TYPES, issues, true)
checkBoolean(action.enabled, `${path}.enabled`, issues, true)
if (typeof action.type === 'string') {
if (seen.has(action.type))
addIssue(issues, `${path}.type`, 'duplicate_action', '动作不能重复')
seen.add(action.type)
if (action.type === 'replyText') {
checkString(action.text, `${path}.text`, issues, {
required: action.enabled === true,
max: 2_000,
allowEmpty: action.enabled !== true
})
} else if (action.text !== undefined) {
addIssue(issues, `${path}.text`, 'unknown_field', '此动作不支持 text')
}
}
})
}
function validateTarget(
value: unknown,
path: string,
issues: AgentAutomationValidationIssue[]
): void {
const target = checkObject(value, path, ['type', 'contactId'], issues)
if (!target) return
checkEnum(target.type, `${path}.type`, TARGET_TYPES, issues, true)
if (target.type === 'contact') {
checkString(target.contactId, `${path}.contactId`, issues, { required: true, max: 300 })
} else if (target.contactId !== undefined) {
addIssue(issues, `${path}.contactId`, 'unexpected_field', '仅 contact 目标可设置 contactId')
}
}
function validateScheduledReport(raw: JsonRecord, issues: AgentAutomationValidationIssue[]): void {
checkBoolean(raw.targetNeedsReview, 'scheduledReport.targetNeedsReview', issues)
if (raw.targetNeedsReview === true) {
addIssue(
issues,
'scheduledReport.targetNeedsReview',
'target_needs_review',
'发送目标需要在 TraceMemo 中重新确认'
)
}
if (raw.postfixText !== undefined) {
checkString(raw.postfixText, 'scheduledReport.postfixText', issues, {
max: SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
allowEmpty: true
})
}
const schedule = checkObject(raw.schedule, 'scheduledReport.schedule', ['time'], issues)
if (schedule) {
if (typeof schedule.time !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(schedule.time)) {
addIssue(issues, 'scheduledReport.schedule.time', 'invalid_time', '时间必须使用 HH:mm 格式')
}
}
const report = checkObject(
raw.report,
'scheduledReport.report',
[
'sourceConversationId',
'range',
'messageTypes',
'templateId',
'memberNameMode',
'timeoutSeconds'
],
issues
)
if (report) {
checkString(
report.sourceConversationId,
'scheduledReport.report.sourceConversationId',
issues,
{
required: true,
max: 300
}
)
checkEnum(report.range, 'scheduledReport.report.range', SCHEDULE_RANGES, issues, true)
if (!Array.isArray(report.messageTypes) || report.messageTypes.length === 0) {
addIssue(issues, 'scheduledReport.report.messageTypes', 'required', '至少选择一种消息类型')
} else {
report.messageTypes.forEach((type, index) =>
checkEnum(
type,
`scheduledReport.report.messageTypes[${index}]`,
MESSAGE_TYPES,
issues,
true
)
)
}
if (!isSelectableReportTemplateId(report.templateId)) {
addIssue(issues, 'scheduledReport.report.templateId', 'invalid_template', '模板 ID 不受支持')
}
checkEnum(
report.memberNameMode,
'scheduledReport.report.memberNameMode',
MEMBER_NAME_MODES,
issues,
true
)
checkInteger(
report.timeoutSeconds,
'scheduledReport.report.timeoutSeconds',
issues,
SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS,
SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS,
true
)
}
validateTarget(raw.target, 'scheduledReport.target', issues)
}
function validateLeaveNotification(
raw: JsonRecord,
issues: AgentAutomationValidationIssue[]
): void {
checkBoolean(raw.targetNeedsReview, 'leaveNotification.targetNeedsReview', issues)
if (raw.targetNeedsReview === true) {
addIssue(
issues,
'leaveNotification.targetNeedsReview',
'target_needs_review',
'发送目标需要在 TraceMemo 中重新确认'
)
}
validateTarget(raw.target, 'leaveNotification.target', issues)
checkEnum(raw.notifyScope, 'leaveNotification.notifyScope', ['all', 'selected'], issues, true)
if (!Array.isArray(raw.notifyRoomIds)) {
addIssue(issues, 'leaveNotification.notifyRoomIds', 'invalid_type', '必须是群聊 ID 数组')
} else {
if (raw.notifyRoomIds.length > 500) {
addIssue(issues, 'leaveNotification.notifyRoomIds', 'too_many_items', '最多支持 500 个群聊')
}
raw.notifyRoomIds.forEach((id, index) =>
checkString(id, `leaveNotification.notifyRoomIds[${index}]`, issues, {
required: true,
max: 300
})
)
}
checkString(raw.template, 'leaveNotification.template', issues, { required: true, max: 2_000 })
const templateResult = validateGroupExitNotificationTemplate(raw.template)
if (!templateResult.valid) {
addIssue(
issues,
'leaveNotification.template',
'invalid_template',
templateResult.error || '模板无效'
)
}
}
/** Strict structural validation before the existing tolerant Store normalizer is called. */
export function validateAutomationDraftShape(input: unknown): {
valid: boolean
issues: AgentAutomationValidationIssue[]
normalized?: AutomationRuleDraft
} {
const issues: AgentAutomationValidationIssue[] = []
const raw = checkObject(
input,
'draft',
[
'name',
'enabled',
'ruleType',
'trigger',
'scope',
'conditions',
'actions',
'cooldownSeconds',
'replyDelaySeconds',
'leaveNotification',
'scheduledReport'
],
issues
)
if (!raw) return { valid: false, issues }
checkString(raw.name, 'name', issues, { required: true, max: 100 })
checkEnum(raw.ruleType, 'ruleType', RULE_TYPES, issues, true)
if (raw.enabled !== undefined) checkBoolean(raw.enabled, 'enabled', issues, true)
if (raw.enabled === true) {
addIssue(
issues,
'enabled',
'use_enable_operation',
'创建和校验不能启用规则,请使用 enable 操作'
)
}
checkEnum(raw.trigger, 'trigger', ['message'], issues, false)
checkEnum(raw.scope, 'scope', SCOPES, issues, false)
checkInteger(raw.cooldownSeconds, 'cooldownSeconds', issues, 0, 86_400)
checkInteger(
raw.replyDelaySeconds,
'replyDelaySeconds',
issues,
0,
AUTOMATION_REPLY_DELAY_MAX_SECONDS
)
if (raw.ruleType !== 'scheduled_report' && raw.scheduledReport !== undefined) {
addIssue(issues, 'scheduledReport', 'unexpected_field', '此规则类型不支持 scheduledReport')
}
if (raw.ruleType !== 'leave_notification' && raw.leaveNotification !== undefined) {
addIssue(issues, 'leaveNotification', 'unexpected_field', '此规则类型不支持 leaveNotification')
}
if (raw.ruleType === 'daily_report') validateDailyReport(raw, issues)
if (raw.ruleType === 'scheduled_report') {
if (raw.scheduledReport === undefined) {
addIssue(issues, 'scheduledReport', 'required', '必须提供定时日报配置')
} else {
const scheduled = checkObject(
raw.scheduledReport,
'scheduledReport',
['schedule', 'report', 'target', 'postfixText', 'targetNeedsReview'],
issues
)
if (scheduled) validateScheduledReport(scheduled, issues)
}
}
if (raw.ruleType === 'leave_notification') {
if (raw.leaveNotification === undefined) {
addIssue(issues, 'leaveNotification', 'required', '必须提供退群通知配置')
} else {
const leave = checkObject(
raw.leaveNotification,
'leaveNotification',
['target', 'template', 'notifyScope', 'notifyRoomIds', 'targetNeedsReview'],
issues
)
if (leave) validateLeaveNotification(leave, issues)
}
}
if (issues.length) return { valid: false, issues }
const ruleType = raw.ruleType as AutomationRuleType
const normalized = normalizeRuleDraft({
...raw,
enabled: false,
...(ruleType === 'daily_report'
? {}
: {
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [],
cooldownSeconds: 0
})
})
return { valid: true, issues: [], normalized: { ...normalized, enabled: false } }
}
+70
View File
@@ -0,0 +1,70 @@
import type { AutomationExecution, AutomationRuleType } from '../automation'
export interface AgentApiErrorBody {
error: {
code: string
message: string
details?: unknown
}
requestId: string
}
export interface CapabilityAvailability {
supported: boolean
available: boolean
reason?: string
operations?: string[]
}
export interface ApplicationCapabilities {
version: string
apiVersion: 'v1'
database: { ready: boolean }
query: CapabilityAvailability
automations: CapabilityAvailability & {
ruleTypes: AutomationRuleType[]
operations: string[]
}
groupExitMonitor: CapabilityAvailability
groupStats: CapabilityAvailability
wechat: {
personal: CapabilityAvailability & {
status: string
content: { text: boolean; image: boolean; voice: boolean }
}
ilink: CapabilityAvailability & { status: string }
}
}
export interface AgentAutomationValidationIssue {
path: string
code: string
message: string
details?: unknown
}
export interface AgentAutomationValidationResult {
valid: boolean
issues: AgentAutomationValidationIssue[]
normalized?: unknown
effects?: Record<string, unknown>
nextRunAt?: string | null
capabilities?: Record<string, unknown>
}
export interface AgentAutomationExecution extends Pick<
AutomationExecution,
| 'executionId'
| 'ruleId'
| 'ruleName'
| 'trigger'
| 'status'
| 'triggerTime'
| 'durationMs'
| 'sourceDisplayName'
> {
ruleType?: AutomationRuleType
startedAt: string
finishedAt: string | null
error?: string
}
@@ -0,0 +1,28 @@
export interface AgentGroupExitMonitorState {
enabled: boolean
running: boolean
nativeMonitorActive: boolean
monitoredConversationIds: string[]
monitoredGroupCount: number
monitorSelectionConfigured: boolean
lastCheckedAt: string | null
lastReadAt: string | null
eventCount: number
unreadCount: number
}
export interface AgentGroupExitMonitorEvent {
eventId: string
conversationId: string
groupName: string
memberId: string
memberName: string
wechatName: string
groupRemark: string
contactRemark: string
previousCount: number
currentCount: number
delta: number
message: string
detectedAt: string
}
+32
View File
@@ -0,0 +1,32 @@
export interface AgentGroupMemberStats {
conversation: {
id: string
name: string
}
conversationId: string
range: {
start: string
end: string
}
memberCount: number
activeMemberCount: number
silentMemberCount: number
activeMembers: Array<{
memberId: string
displayName: string
groupNickname: string
messageCount: number
lastMessageAt: string | null
}>
silentMembers: Array<{
memberId: string
displayName: string
groupNickname: string
}>
freshness: 'fresh' | 'stale' | 'unknown'
complete: boolean
limitations: string[]
unattributedMessages: number
excludedSystemMessages: number
firstMessageAt: string | null
}
+33
View File
@@ -21,6 +21,39 @@ export const LOCAL_API_ENDPOINTS = {
path: '/api/v1/scheduled-reports',
queryKeys: []
},
'app-capabilities': { method: 'GET', path: '/api/v1/capabilities', queryKeys: [] },
automations: {
method: 'GET',
path: '/api/v1/automations',
queryKeys: ['type', 'enabled']
},
'automation-create': { method: 'POST', path: '/api/v1/automations', queryKeys: [] },
'automation-validate': { method: 'POST', path: '/api/v1/automations/validate', queryKeys: [] },
'automation-executions': {
method: 'GET',
path: '/api/v1/automations/executions',
queryKeys: ['ruleId', 'status', 'since', 'until', 'limit']
},
'group-exit-monitor': {
method: 'GET',
path: '/api/v1/monitors/group-exits',
queryKeys: []
},
'group-exit-monitor-update': {
method: 'PATCH',
path: '/api/v1/monitors/group-exits',
queryKeys: []
},
'group-exit-events': {
method: 'GET',
path: '/api/v1/monitors/group-exits/events',
queryKeys: ['conversationId', 'since', 'until', 'limit']
},
'group-member-stats': {
method: 'GET',
path: '/api/v1/groups/{conversationId}/member-stats',
queryKeys: ['conversationId', 'start', 'end']
},
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] },
'agent-status': { method: 'GET', path: '/api/v1/agent/status', queryKeys: [] },
'agent-group-report': { method: 'POST', path: '/api/v1/agent/group-report', queryKeys: [] },
@@ -1,7 +1,10 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { AccountSummary } from '../../src/renderer/src/components/account/AccountSummary'
import { AccountOverview } from '../../src/renderer/src/features/settings/account-database/AccountOverview'
import { ConnectionHealthSection } from '../../src/renderer/src/features/settings/account-database/ConnectionHealthSection'
import type { ConnectionDiagnostic } from '../../src/renderer/src/features/settings/account-database/types'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
describe('account database controls', () => {
@@ -61,4 +64,54 @@ describe('account database controls', () => {
expect(screen.getByRole('button', { name: '打开账号目录' })).toBeDisabled()
expect(screen.getByRole('button', { name: '复制账号目录' })).toBeDisabled()
})
it('renders connection states with SVG glyphs and keeps checking accessible', () => {
const diagnostics: ConnectionDiagnostic[] = [
{ id: 'db-key', label: '数据库密钥', status: 'success', result: '已用于当前连接' },
{ id: 'contacts', label: '联系人索引', status: 'warning', result: '索引待更新' },
{ id: 'messages', label: '消息数据库', status: 'error', result: '读取失败' },
{ id: 'identity', label: '账号身份', status: 'idle', result: '尚未检测' },
{ id: 'image-key', label: '图片解密密钥', status: 'unavailable', result: '未配置' }
]
const { container, rerender } = render(<ConnectionHealthSection diagnostics={diagnostics} />)
const icons = container.querySelectorAll('.settings-diagnostic-icon:not(.checking)')
expect(icons).toHaveLength(diagnostics.length)
icons.forEach((icon) => {
expect(icon).toHaveAttribute('aria-hidden', 'true')
expect(icon.querySelector('svg')).toHaveAttribute('viewBox', '0 0 24 24')
})
expect(container.textContent).not.toMatch(/[✓!×—]/)
rerender(
<ConnectionHealthSection
diagnostics={[{ id: 'db-key', label: '数据库密钥', status: 'checking', result: '检测中' }]}
/>
)
expect(screen.getByLabelText('检测中')).toHaveClass('checking')
expect(container.querySelector('.checking svg')).toBeNull()
})
it('keeps the account settings button clickable with a centered-viewBox SVG', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(
<AccountSummary
selfInfo={{
wxid: 'wxid_fixture',
nickname: '测试账号',
accountRoot: '/fixture/account'
}}
dbReady
onClick={onClick}
/>
)
const button = screen.getByRole('button', { name: '设置' })
expect(button.querySelector('svg')).toHaveAttribute('viewBox', '0 0 24 24')
expect(button.querySelector('svg circle')).toHaveAttribute('cx', '12')
expect(button.querySelector('svg circle')).toHaveAttribute('cy', '12')
await user.click(button)
expect(onClick).toHaveBeenCalledOnce()
})
})
@@ -1,8 +1,9 @@
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
import { type GroupMemberStatsResult } from '../../src/shared/group-stats'
import type { Contact } from '../../src/shared/types'
const groupContact: Contact = {
@@ -19,9 +20,38 @@ const userContact: Contact = {
type: 'user'
}
/** 时区无关:用本地字段构造,别写死 UTC 偏移。 */
const at = (year: number, month: number, day: number): number =>
new Date(year, month - 1, day).getTime()
const statsResult = (): GroupMemberStatsResult => ({
conversationId: groupContact.md5,
startTime: at(2026, 6, 15),
endTime: at(2026, 9, 4),
freshness: 'fresh',
complete: true,
memberCount: 2,
activeMemberCount: 1,
silentMemberCount: 1,
activeMembers: [
{
senderId: 'wxid_a',
displayName: '张三',
groupNickname: '老张',
messageCount: 12,
lastMessageTime: at(2026, 9, 1)
}
],
silentMembers: [{ senderId: 'wxid_b', displayName: '李四', groupNickname: '' }],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: at(2026, 6, 15),
limitations: []
})
beforeEach(() => {
;(window as unknown as { api: Record<string, unknown> }).api = {
getGroupMemberStats: vi.fn(),
getGroupMemberStats: vi.fn().mockResolvedValue(statsResult()),
getPersonalWechatSenderStatus: vi.fn().mockResolvedValue({ canSendText: false })
}
})
@@ -44,22 +74,30 @@ function renderHeader(contact: Contact, isGroupChat: boolean): void {
)
}
async function openMoreMenu(): Promise<void> {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: '更多功能' }))
await waitFor(() => expect(screen.getByText('刷新数据')).toBeTruthy())
}
describe('ChatHeader · 群发言统计入口', () => {
it('群聊显示「群发言统计」入口', async () => {
it('群聊把「群发言统计」做成直接按钮,不再藏在「更多功能」下拉里', () => {
renderHeader(groupContact, true)
await openMoreMenu()
expect(screen.getByText('群发言统计')).toBeTruthy()
// 旧入口连同「刷新数据」一起下线,不能再回来。
expect(screen.queryByRole('button', { name: '更多功能' })).toBeNull()
expect(screen.queryByRole('menuitem', { name: '刷新数据' })).toBeNull()
// 是普通按钮,不是菜单触发器 —— 点了就该开面板,不该弹下拉。
expect(screen.getByRole('button', { name: '群发言统计' })).not.toHaveAttribute('aria-haspopup')
})
it('非群聊不显示「群发言统计」入口', async () => {
it('点击入口直接打开群发言统计面板', async () => {
const user = userEvent.setup()
renderHeader(groupContact, true)
await user.click(screen.getByRole('button', { name: '群发言统计' }))
expect(await screen.findByRole('dialog', { name: '群发言统计 · 研发群' })).toBeInTheDocument()
})
it('非群聊不显示「群发言统计」入口', () => {
renderHeader(userContact, false)
await openMoreMenu()
expect(screen.queryByText('群发言统计')).toBeNull()
expect(screen.queryByRole('button', { name: '群发言统计' })).toBeNull()
})
})
-32
View File
@@ -34,37 +34,6 @@ describe('chat menus', () => {
expect(trigger).toHaveFocus()
})
it('closes the chat More menu with Escape and invokes refresh data once', async () => {
const user = userEvent.setup()
const onRefreshData = vi.fn()
renderWithTooltip(
<ChatHeader
contact={contact}
isGroupChat
loadedCount={3}
filteredCount={3}
contentFilter=""
isAiLoading={false}
onContentFilterChange={vi.fn()}
onRefresh={vi.fn()}
onRefreshData={onRefreshData}
onTestSend={vi.fn()}
onOpenAiSettings={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: '更多功能' })
await user.click(trigger)
expect(screen.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menuitem', { name: '刷新数据' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: '刷新数据' }))
expect(onRefreshData).toHaveBeenCalledOnce()
})
it('keeps header search and actions wired to their existing callbacks', async () => {
const user = userEvent.setup()
const onContentFilterChange = vi.fn()
@@ -80,7 +49,6 @@ describe('chat menus', () => {
isAiLoading={false}
onContentFilterChange={onContentFilterChange}
onRefresh={onRefresh}
onRefreshData={vi.fn()}
onTestSend={vi.fn()}
onOpenAiSettings={onOpenAiSettings}
/>
+91 -3
View File
@@ -25,6 +25,7 @@ const previewMessage = (contact: Contact): Message => ({
describe('ExportWorkspace multi-chat selection', () => {
beforeEach(() => {
localStorage.clear()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
@@ -41,9 +42,9 @@ describe('ExportWorkspace multi-chat selection', () => {
const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false })),
exportTasks: ExportTaskRecord[] = []
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
): { loadPreviewMessages: ReturnType<typeof vi.fn>; unmount: () => void } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render(
const workspace = render(
<ExportWorkspace
contacts={contacts}
initialContact={contacts[0]}
@@ -56,7 +57,7 @@ describe('ExportWorkspace multi-chat selection', () => {
onCancelExport={vi.fn(async () => undefined)}
/>
)
return { loadPreviewMessages }
return { loadPreviewMessages, unmount: workspace.unmount }
}
it('defaults to one chat, forces HTML after adding another, merges the preview, and resets locally', async () => {
@@ -221,6 +222,93 @@ describe('ExportWorkspace multi-chat selection', () => {
endTime: Math.floor(new Date('2026-08-02T18:45').getTime() / 1000)
})
})
it('restores export preferences after leaving and reopening the workspace', async () => {
const { unmount } = renderWorkspace()
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '导出格式' })).getByRole('radio', {
name: /HTML/
})
)
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '自定义时间'
})
)
await userEvent.type(screen.getByLabelText('开始时间'), '2026-08-01T09:30')
await userEvent.type(screen.getByLabelText('结束时间'), '2026-08-02T18:45')
await userEvent.click(screen.getByRole('checkbox', { name: '文字' }))
await userEvent.click(screen.getByRole('checkbox', { name: '语音' }))
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '消息显示名称' })).getByRole('radio', {
name: '微信名'
})
)
await userEvent.click(
within(screen.getByRole('radiogroup', { name: 'HTML 打包方式' })).getByRole('radio', {
name: 'HTML 资源包并压缩为 ZIP'
})
)
await userEvent.click(screen.getByRole('checkbox', { name: '优先导出原图' }))
await userEvent.type(screen.getByLabelText('文件名称'), '持续更新的聊天档案')
await userEvent.click(screen.getByRole('button', { name: '选择位置' }))
expect(await screen.findByText('/fixture/export/持续更新的聊天档案.zip')).toBeVisible()
unmount()
renderWorkspace()
expect(
within(screen.getByRole('radiogroup', { name: '导出格式' })).getByRole('radio', {
name: /HTML/
})
).toBeChecked()
expect(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '自定义时间'
})
).toBeChecked()
expect(screen.getByLabelText('开始时间')).toHaveValue('2026-08-01T09:30')
expect(screen.getByLabelText('结束时间')).toHaveValue('2026-08-02T18:45')
expect(screen.getByRole('checkbox', { name: '文字' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '语音' })).toBeChecked()
expect(
within(screen.getByRole('radiogroup', { name: '消息显示名称' })).getByRole('radio', {
name: '微信名'
})
).toBeChecked()
expect(
within(screen.getByRole('radiogroup', { name: 'HTML 打包方式' })).getByRole('radio', {
name: 'HTML 资源包并压缩为 ZIP'
})
).toBeChecked()
expect(screen.getByRole('checkbox', { name: '优先导出原图' })).not.toBeChecked()
expect(screen.getByLabelText('文件名称')).toHaveValue('持续更新的聊天档案')
expect(screen.getByText('/fixture/export/持续更新的聊天档案.zip')).toBeVisible()
})
it('preserves the selected range while switching to and from all export', async () => {
renderWorkspace()
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '最近 7 天'
})
)
await userEvent.click(screen.getByRole('button', { name: /全部导出/ }))
expect(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '全部时间'
})
).toBeChecked()
await userEvent.click(screen.getByRole('button', { name: /聊天 A/ }))
expect(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '最近 7 天'
})
).toBeChecked()
})
})
describe('ExportTaskCenter details', () => {
+57 -20
View File
@@ -197,15 +197,6 @@ test('NAV-04 exit monitor management can save an empty scope and show the setup
await expect(fixture.page.getByRole('heading', { name: '管理群聊', exact: true })).toBeVisible()
await expect(fixture.page.getByText('发送能力已就绪', { exact: true })).toBeVisible()
await expect(fixture.page.getByRole('checkbox', { name: '监控产品测试群' })).toBeChecked()
await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click()
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/用户: \{user\}/)
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/群备注: \{groupRemark\}/)
const customTemplate = '[退群监测]\n用户: {user}\n群备注: {groupRemark}'
await fixture.page.getByLabel('退群监测模板内容').fill(customTemplate)
await fixture.page.getByRole('button', { name: '保存模板' }).click()
await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click()
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(customTemplate)
await fixture.page.keyboard.press('Escape')
await fixture.page.getByRole('checkbox', { name: '监控产品测试群' }).click()
await fixture.page.getByRole('checkbox', { name: '监控折叠群聊样本' }).click()
await fixture.page.getByRole('button', { name: '保存监控群聊' }).click()
@@ -216,7 +207,47 @@ test('NAV-04 exit monitor management can save an empty scope and show the setup
}
})
test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', async () => {
/**
* 退群通知的**内容模板**已随「退群通知」一起迁进自动化,管理群聊页只负责
* 「监测哪些群」。这条用例锁住新家:模板可编辑、可保存,且真的落库。
*/
test('NAV-05 leave notification template is editable in the automation workspace', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await fixture.page.getByRole('button', { name: '配置退群通知自动化' }).click()
await expect(fixture.page.getByRole('heading', { name: '自动化', exact: true })).toBeVisible()
await expect(fixture.page.getByRole('heading', { name: '退群通知' })).toBeVisible()
const template = fixture.page.getByLabel('退群通知模板内容')
await expect(template).toHaveValue(/用户: \{user\}/)
await expect(template).toHaveValue(/群备注: \{groupRemark\}/)
const customTemplate = '[退群监测]\n用户: {user}\n群备注: {groupRemark}'
await template.fill(customTemplate)
await fixture.page.getByRole('button', { name: '保存' }).click()
/*
* `exact: true` 不能省:Radix 额外渲染一个 `role="status"` 的无障碍播报节点,
* 文本是 `Notification 已保存「退群通知」`,且由 `useNextFrame` **下一帧才填** ——
* 裸 regex 会命中它造成 strict mode violation,且是否命中取决于帧时序。
*/
await expect(fixture.page.getByText('已保存「退群通知」', { exact: true })).toBeVisible()
await fixture.page.reload()
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await fixture.page.getByRole('button', { name: '配置退群通知自动化' }).click()
await expect(fixture.page.getByLabel('退群通知模板内容')).toHaveValue(customTemplate)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('CHAT-01 archive group stats entry opens the panel and keeps the page usable', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
@@ -227,15 +258,6 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy
await conversationSearch.fill('')
await fixture.page.getByRole('button', { name: '刷新会话列表' }).click()
await fixture.page.getByText('产品测试群', { exact: true }).click()
const moreButton = fixture.page.getByRole('button', { name: '更多' })
await moreButton.click()
await expect(fixture.page.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(fixture.page.getByRole('menuitem', { name: '刷新数据' })).toHaveCount(0)
await expect(moreButton).toBeFocused()
await moreButton.click()
await fixture.page.getByRole('menuitem', { name: '刷新数据' }).click()
await expect(fixture.page.getByRole('heading', { name: '产品测试群' })).toBeVisible()
await fixture.page.getByRole('button', { name: '搜索当前聊天' }).click()
@@ -258,6 +280,21 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
/*
* 「更多功能」下拉连同「刷新数据」一起下线,群发言统计改为一颗直接按钮。
* 放最后、且不断言关闭:Radix 的 Escape 关闭依赖层监听注册时机,全量跑时偶发不生效,
* 那是组件库行为、不是本次改动的契约(单独连打 8 次均即时关闭)。
*/
await expect(fixture.page.getByRole('button', { name: '更多功能' })).toHaveCount(0)
await expect(fixture.page.getByRole('menuitem', { name: '刷新数据' })).toHaveCount(0)
const statsButton = fixture.page.getByRole('button', { name: '群发言统计', exact: true })
await expect(statsButton).toBeVisible()
await statsButton.click()
await expect(
fixture.page.getByRole('dialog', { name: '群发言统计 · 产品测试群' })
).toBeVisible()
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
@@ -773,7 +810,7 @@ test('AGENT-01 Agent Hub controls stay usable in the default offline layout', as
await fixture.page.keyboard.press('Escape')
await expect(logSource).toBeFocused()
await expect(fixture.page.getByRole('button', { name: '复制日志' })).toBeDisabled()
await fixture.page.getByRole('button', { name: '清空' }).click()
await fixture.page.getByRole('button', { name: '清空', exact: true }).click()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
+146 -99
View File
@@ -1,80 +1,146 @@
import { expect, test } from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
import { launchTestApp } from './support/electron'
const debugNotificationEnabled = ['1', 'true', 'yes', 'on'].includes(
String(process.env.VITE_SCHEDULED_REPORT_DEBUG || '')
.trim()
.toLowerCase()
)
/**
* 定时日报的 E2E。
*
* 「定时日报」已从「日报 → 定时日报」独立页迁到「自动化 → 规则 → 定时日报」,
* 本文件走的是**迁移后**的真实路径:
*
* 日报(提示条)→「去自动化配置 →」→ 自动化 · 定时日报(规则列表)→ 新建 → 编辑器
*
* 断言一律打在真实模型渲染出的**默认值与真实选项**上(范围 / 模板 / 成员名称 /
* 消息类型 / 发送目标),而不是「页面上有个按钮」这种空转检查。
*/
test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => {
/** fixture 约定的固定时刻:2026-08-27 08:30 (+08:00)。 */
const FIXTURE_NOW = Date.parse('2026-08-27T08:30:00+08:00')
/** 从时间线进入「自动化 → 定时日报」的规则列表。 */
async function openScheduledReportRuleList(page: Page): Promise<void> {
await page.getByRole('button', { name: '日报' }).click()
await page.getByRole('button', { name: '去自动化配置 →' }).click()
await expect(page.getByRole('radiogroup', { name: '切换规则类型' })).toBeVisible()
}
/** 列表 → 新建 → 编辑器。 */
async function openScheduledReportEditor(page: Page): Promise<void> {
await openScheduledReportRuleList(page)
await page.getByRole('button', { name: '+ 新建定时日报' }).first().click()
await expect(page.getByRole('heading', { name: '新建定时日报' })).toBeVisible()
}
test('SCHEDULED-REPORT-UI-01 creates a scheduled report rule from the automation workspace without viewport overflow', async () => {
test.skip(
process.platform !== 'darwin' && process.platform !== 'win32',
'The scheduled report send capability requires macOS or Windows'
)
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
const fixture = await launchTestApp({ now: FIXTURE_NOW })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
const page = fixture.page
try {
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
await expect(fixture.page.getByRole('heading', { name: '定时日报', exact: true })).toBeVisible()
await expect(fixture.page.getByText('✓ 微信发送能力已就绪')).toBeVisible()
await openScheduledReportRuleList(page)
const createButton = fixture.page.getByRole('button', { name: /新建定时日报/ }).first()
await expect(createButton).toBeEnabled()
await createButton.click()
// 深链把「定时日报」这个类型选中,并且先给列表而不是直接甩一个空表单。
await expect(page.getByRole('radio', { name: '定时日报' })).toBeChecked()
await expect(page.getByText('还没有定时日报')).toBeVisible()
const dialog = fixture.page.getByRole('dialog', { name: '新建定时日报' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('button', { name: /产品测试群 .*微信群聊/ })).toBeVisible()
await expect(dialog.getByPlaceholder('搜索群聊')).toBeVisible()
await expect(dialog.getByRole('button', { name: '创建定时日报' })).toBeVisible()
await page.getByRole('button', { name: '+ 新建定时日报' }).first().click()
await expect(page.getByRole('heading', { name: '新建定时日报' })).toBeVisible()
const customRange = dialog.locator('[aria-disabled="true"]')
await expect(customRange).toHaveCount(1)
const customLabel = customRange.getByText('自定义', { exact: true })
const customHint = customRange.getByText('即将支持', { exact: true })
await expect(customLabel).toHaveCSS('white-space', 'nowrap')
await expect(customHint).toHaveCSS('white-space', 'nowrap')
expect((await customLabel.boundingBox())?.height).toBeLessThanOrEqual(20)
expect((await customHint.boundingBox())?.height).toBeLessThanOrEqual(20)
// 编辑器是内嵌面板(不是弹窗),三段结构齐全。
for (const heading of ['1 · 什么时候触发', '2 · 生成什么日报', '3 · 生成后发送到哪里']) {
await expect(page.getByRole('heading', { name: heading })).toBeVisible()
}
const selects = dialog.getByRole('combobox')
await expect(selects).toHaveCount(2)
await selects.nth(0).click()
const templateOption = fixture.page.getByRole('option', { name: /Mobile 01.*微信信息流/ })
await expect(templateOption).toBeVisible()
await templateOption.click()
await expect(selects.nth(0)).toContainText('微信信息流')
// 触发段:默认 18:30,且「预计下次执行」按本地时区算出当日槽位。
await expect(page.getByRole('textbox', { name: '执行时间小时' })).toHaveValue('18')
await expect(page.getByRole('textbox', { name: '执行时间分钟' })).toHaveValue('30')
await expect(page.getByTestId('scheduled-next-run')).toHaveText('今日 18:30')
await selects.nth(1).click()
const memberOption = fixture.page.getByRole('option', { name: '微信昵称' })
await expect(memberOption).toBeVisible()
await memberOption.click({ force: true })
await expect(selects.nth(1)).toContainText('微信昵称', { timeout: 8000 })
// 还没填必填项就必须**说不出原因地**拦住保存。
const saveButton = page.getByRole('button', { name: '保存', exact: true })
await expect(saveButton).toBeDisabled()
await expect(page.getByTestId('scheduled-save-blocker')).toHaveText('请填写任务名称')
/*
* 「自定义」区间在旧页面是 aria-disabled 的占位,新编辑器直接把范围收成一个下拉 ——
* 所以这里不再断言那个占位,改成断言下拉本身接的是真实取值。
*/
await expect(page.getByRole('combobox', { name: '日报范围' })).toHaveText('今日')
// 来源群候选来自真实群列表,且搜索框真的过滤。
await expect(page.getByRole('radio', { name: '产品测试群' })).toBeVisible()
await expect(page.getByRole('radio', { name: '折叠群聊样本' })).toBeVisible()
await page.getByRole('textbox', { name: '搜索群聊' }).fill('折叠')
await expect(page.getByRole('radio', { name: '产品测试群' })).toHaveCount(0)
await expect(page.getByRole('radio', { name: '折叠群聊样本' })).toBeVisible()
await page.getByRole('textbox', { name: '搜索群聊' }).fill('')
await expect(page.getByRole('radio', { name: '产品测试群' })).toBeVisible()
// 模板下拉:默认经典日报,能切到社区模板。
const templateSelect = page.getByRole('combobox', { name: '日报模板' })
await expect(templateSelect).toHaveText('经典日报')
await templateSelect.click()
await page.getByRole('option', { name: 'Mobile 01 · 微信信息流' }).click()
await expect(templateSelect).toHaveText('Mobile 01 · 微信信息流')
// 成员名称下拉:默认群昵称,能切到微信昵称。
const memberSelect = page.getByRole('combobox', { name: '成员名称' })
await expect(memberSelect).toHaveText('群昵称')
await memberSelect.click()
await page.getByRole('option', { name: '微信昵称' }).click()
await expect(memberSelect).toHaveText('微信昵称')
await expect(page.getByRole('textbox', { name: '日报生成超时' })).toHaveValue('300')
// 发送目标四选一,默认「文件传输助手」。
await expect(page.getByRole('radio', { name: '文件传输助手' })).toBeChecked()
// 整页不允许出现横向滚动条(布局回归的硬门槛)。
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
const bounds = await dialog.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.y).toBeGreaterThanOrEqual(0)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(
await fixture.page.evaluate(() => window.innerHeight)
)
await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('E2E 定时日报')
await dialog.getByRole('radio', { name: '今日' }).click()
await dialog.getByRole('button', { name: '创建定时日报' }).click()
await expect(dialog).toHaveCount(0)
await expect(fixture.page.getByText('E2E 定时日报', { exact: true })).toBeVisible()
await expect(fixture.page.getByText('今日', { exact: true })).toBeVisible()
/*
* 阻断原因是**逐级**暴露的:填了名称就轮到来源群,选了群才放行。
* 这里按顺序走一遍,确认拦的是真实缺失项而不是一个笼统的"保存失败"。
*/
await page.getByRole('textbox', { name: '定时日报任务名称' }).fill('E2E 定时日报')
await expect(page.getByTestId('scheduled-save-blocker')).toHaveText('请选择日报来源群')
await expect(saveButton).toBeDisabled()
await fixture.page.getByRole('button', { name: '设置' }).click()
await fixture.page.getByRole('button', { name: '微信发送', exact: true }).click()
await expect(fixture.page.getByRole('heading', { name: '微信发送' })).toBeVisible()
await expect(fixture.page.getByRole('heading', { name: '发送能力', exact: true })).toBeVisible()
await page.getByRole('radio', { name: '产品测试群' }).click()
await expect(page.getByTestId('scheduled-save-blocker')).toHaveCount(0)
await expect(saveButton).toBeEnabled()
await saveButton.click()
// toast 必须 `exact`:Radix 的无障碍播报节点文案是 `Notification 已创建「…」`,子串会命中 2 个。
await expect(page.getByText('已创建「E2E 定时日报」', { exact: true })).toBeVisible()
// 回到列表,卡片渲染的是**保存回来的规则**,不是草稿。
await expect(page.getByRole('heading', { name: 'E2E 定时日报' })).toBeVisible()
await expect(page.getByText('每天 18:30')).toBeVisible()
await expect(page.getByRole('button', { name: '+ 新建定时日报' }).first()).toBeVisible()
// 发送能力状态仍是设置页里的同一份事实。
await page.getByRole('button', { name: '设置' }).click()
await page.getByRole('button', { name: '微信发送', exact: true }).click()
await expect(page.getByRole('heading', { name: '微信发送' })).toBeVisible()
/*
* 这一页的区块标题按平台分叉:macOS 用「配置状态」(SetupGuide 统一展示),
* Windows 才额外有「发送能力」卡。旧断言写死了「发送能力」,在 macOS 上恒假。
*
* `exact: true` 不能省:Windows 上同一页还有「发送能力授权」,
* 子串匹配会同时命中 —— 这正是 CI 上 strict mode violation 的来源。
*/
await expect(
page.getByRole('heading', {
name: process.platform === 'darwin' ? '配置状态' : '发送能力',
exact: true
})
).toBeVisible()
expect(pageErrors).toEqual([])
} finally {
@@ -82,59 +148,40 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport
}
})
test('SCHEDULED-REPORT-UI-02 keeps notification switch off when Agent Hub is offline', async () => {
test('SCHEDULED-REPORT-UI-02 keeps the notification switch off and explains why when Agent Hub is offline', async () => {
test.skip(
process.platform !== 'darwin' && process.platform !== 'win32',
'The scheduled report send capability requires macOS or Windows'
)
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
const fixture = await launchTestApp({ now: FIXTURE_NOW })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
const page = fixture.page
try {
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
const notificationSwitch = fixture.page.getByRole('switch', { name: '微信异常通知' })
await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false')
await notificationSwitch.click()
await openScheduledReportEditor(page)
// 异常通知挂在编辑器侧栏,是「定时日报」的全局能力,不是单条规则的配置。
await expect(page.getByRole('heading', { name: '微信异常通知' })).toBeVisible()
await expect(
fixture.page
.getByRole('alert')
.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')
page.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')
).toBeVisible()
/*
* Agent Hub 离线 ⇒ 开关**禁用**(不是"能点但点了没反应")。
* 迁移前这里是个点了会弹报错的开关;新实现把不可用前置成禁用 + 原因 + 去处,
* 所以断言的是禁用状态与原因文案,而不是点击后的错误提示。
*/
const notificationSwitch = page.getByRole('switch', { name: '开启微信异常通知' })
await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false')
await fixture.page.getByRole('button', { name: '前往 Agent Hub' }).click()
await expect(
fixture.page.getByRole('heading', { name: 'Agent Hub', exact: true })
).toBeVisible()
} finally {
await fixture.close()
}
})
await expect(notificationSwitch).toBeDisabled()
test('SCHEDULED-REPORT-UI-03 sends a simulated error notification in debug mode', async () => {
test.skip(
(process.platform !== 'darwin' && process.platform !== 'win32') || !debugNotificationEnabled,
'The scheduled report debug action requires a supported platform and VITE_SCHEDULED_REPORT_DEBUG=true'
)
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
try {
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
await fixture.page
.getByRole('button', { name: /新建定时日报/ })
.first()
.click()
// 能力未就绪时不给「发送测试」—— 不给一个点了必然失败的按钮。
await expect(page.getByRole('button', { name: '发送测试' })).toHaveCount(0)
const dialog = fixture.page.getByRole('dialog', { name: '新建定时日报' })
await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('调试定时日报')
await dialog.getByRole('button', { name: '创建定时日报' }).click()
await expect(dialog).toHaveCount(0)
await page.getByRole('button', { name: '去连接 Agent Hub' }).click()
await expect(page.getByRole('heading', { name: 'Agent Hub', exact: true })).toBeVisible()
const debugButton = fixture.page.getByRole('button', { name: '测试错误信息发送' })
await expect(debugButton).toBeVisible()
await debugButton.click()
await expect(
fixture.page.getByText('测试错误信息已发送到 Agent Hub 微信通知接收者')
).toBeVisible()
await expect(fixture.page.getByText('定时日报错误通知测试')).toBeVisible()
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
+133 -2
View File
@@ -445,10 +445,106 @@ handle('automation:getStatus', () => ({
message: connected ? '个人微信发送能力已就绪' : '尚未绑定个人微信发送能力'
}
}))
/*
* 定时日报是 `ruleType === 'scheduled_report'` 的**真规则**:列表由
* `automation:listRules` 过滤得到,创建 / 更新走 `automation:createRule` /
* `automation:updateRule`。fixture 必须实现这组通道,否则保存后列表读不回来。
*/
const scheduledReportRules = []
let automationRuleSequence = 0
// 「微信异常通知」是全局开关,新旧两套通道共用同一份状态。
let scheduledReportNotificationEnabled = false
handle('automation:listRules', () => [
builtinDailyReportRule(),
structuredClone(leaveNotificationRule)
structuredClone(leaveNotificationRule),
...scheduledReportRules.map((rule) => structuredClone(rule))
])
handle('automation:createRule', (draft) => {
automationRuleSequence += 1
const rule = {
...draft,
id: `fixture-scheduled-rule-${automationRuleSequence}`,
createdAt: fixtureNowMs,
updatedAt: fixtureNowMs
}
scheduledReportRules.push(rule)
return structuredClone(rule)
})
// `updateRule` / `setRuleEnabled` 收的是**单个对象**(`{ id, draft }` / `{ id, enabled }`),
// 与 `createRule(draft)` / `deleteRule(id)` 的位置参数不同 —— preload 就是这么拼的。
handle('automation:updateRule', (input) => {
const { id, draft } = input || {}
const index = scheduledReportRules.findIndex((rule) => rule.id === id)
if (index < 0) return null
scheduledReportRules[index] = {
...scheduledReportRules[index],
...(draft || {}),
id,
updatedAt: fixtureNowMs
}
return structuredClone(scheduledReportRules[index])
})
handle('automation:deleteRule', (id) => {
const index = scheduledReportRules.findIndex((rule) => rule.id === id)
if (index < 0) return false
scheduledReportRules.splice(index, 1)
return true
})
handle('automation:setRuleEnabled', (input) => {
const { id, enabled } = input || {}
const index = scheduledReportRules.findIndex((rule) => rule.id === id)
if (index < 0) return null
scheduledReportRules[index] = {
...scheduledReportRules[index],
enabled: enabled === true,
updatedAt: fixtureNowMs
}
return structuredClone(scheduledReportRules[index])
})
/*
* 微信异常通知(定时日报的全局能力,不是单条规则的配置)。
*
* 能力判定与 `ScheduledReportService#checkNotificationCapability` 一致:**Agent Hub 离线
* 就是不可用**,且要带上那句可操作的原因。fixture 里 Hub 是 offline,所以这里如实回
* 不可用 —— 不为了让开关点得动而假装就绪。
*
* 这四个通道生产环境都有;不注册会被渲染层的降级兜底吞掉,页面看起来"正常",
* 但测的就不是真实文案了。
*/
handle('automation:getScheduledReportNotificationSettings', () => ({
enabled: scheduledReportNotificationEnabled
}))
handle('automation:getScheduledReportNotificationCapability', () =>
agentHubStatus().hub === 'online'
? { ready: true, recipient: 'wxid_fixture_self' }
: {
ready: false,
reason: 'agent_hub_offline',
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
}
)
handle('automation:setScheduledReportNotificationEnabled', (enabled) => {
if (enabled && agentHubStatus().hub !== 'online') {
scheduledReportNotificationEnabled = false
return {
success: false,
data: { enabled: false },
reason: 'agent_hub_offline',
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
}
}
scheduledReportNotificationEnabled = Boolean(enabled)
return { success: true, data: { enabled: scheduledReportNotificationEnabled } }
})
handle('automation:testScheduledReportErrorNotification', (ruleId) =>
scheduledReportRules.some((rule) => rule.id === ruleId)
? {
success: true,
data: { ruleId, notificationId: 'fixture-notification', sentAt: fixtureNowMs }
}
: { success: false, error: '未找到这条定时日报规则' }
)
handle('automation:listGroups', () => automationGroupOptions())
handle('automation:listExecutions', () => [])
handle('automation:clearExecutions', () => true)
@@ -520,7 +616,6 @@ const scheduledReportTasks =
: []
const scheduledReportExecutions = []
const generatedReports = []
let scheduledReportNotificationEnabled = false
const scheduledReportNextRun = (scheduleTime) => {
const [hours, minutes] = String(scheduleTime || '09:00')
.split(':')
@@ -913,6 +1008,30 @@ handle('db:getGroupSnapshot', (md5) =>
}
: null
)
handle('group-stats:getMemberStats', (request) => ({
conversationId: request?.userMd5 || 'group-regular-md5',
startTime: request?.startTime || 0,
endTime: request?.endTime || fixtureNowMs,
freshness: 'fresh',
complete: true,
memberCount: 2,
activeMemberCount: 1,
silentMemberCount: 1,
activeMembers: [
{
senderId: 'wxid_fixture_member',
displayName: '测试成员',
groupNickname: '测试成员',
messageCount: 1,
lastMessageTime: fixtureNowMs
}
],
silentMembers: [{ senderId: 'wxid_fixture_silent', displayName: '沉默成员', groupNickname: '' }],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: fixtureNowMs,
limitations: []
}))
handle('db:getImage', (md5, datName, sessionId, options) =>
md5 === 'unsupported'
? { success: false, error: '不支持的 DAT 版本' }
@@ -1296,6 +1415,18 @@ handle('agent-hub:clearLogs', () => ({ success: true }))
handle('agent-hub:startLogin', () => ({ status: agentHubStatus() }))
handle('agent-hub:cancelLogin', () => ({ status: agentHubStatus() }))
handle('agent-hub:disconnect', () => ({ status: agentHubStatus() }))
/*
* Agent Hub 对话记录(本机 conversations.json 的只读回看)。
*
* fixture 里 Hub 是 offline、也没有任何收发记录,所以恒为空 —— 与
* `agent-hub:getStatus` 的 offline 状态保持一致。
*
* 通道**必须**注册:会话面板挂载即调用,缺 handler 会被渲染层记成 pageerror。
*/
handle('agent-hub:getConversations', () => [])
handle('agent-hub:getConversation', () => null)
handle('agent-hub:clearConversations', () => ({ success: true }))
handle('image:getConfig', () => ({
success: true,
configured: true,
+287
View File
@@ -0,0 +1,287 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
const fixture = vi.hoisted(() => ({
root: `/tmp/tracememo-agent-api-phase2-${process.pid}`,
contacts: [
{
m_nsUsrName: 'room@chatroom',
m_nsNickName: '产品群',
md5: 'room-md5',
type: 'group' as const
},
{
m_nsUsrName: 'wxid_friend',
m_nsNickName: 'Alice',
md5: 'friend-md5',
type: 'user' as const,
wxid: 'wxid_friend'
}
],
state: {
enabled: true,
running: true,
nativeMonitorActive: true,
monitoredRoomIds: ['room@chatroom'],
monitoredGroupCount: 1,
monitorSelectionConfigured: true,
totalEventCount: 2,
lastCheckedAt: 1_728_000_000_000,
lastReadAt: 1_727_000_000_000,
unreadCount: 1
},
events: [
{
id: 'event-2',
contactId: 'room-md5',
roomId: 'room@chatroom',
groupName: '产品群',
memberWxid: 'wxid_b',
memberName: '乙',
wechatName: '乙',
groupRemark: '群乙',
contactRemark: '',
previousCount: 3,
currentCount: 2,
delta: -1,
message: '乙退出了产品群',
detectedAt: 1_728_000_000_000
},
{
id: 'event-1',
contactId: 'room-md5',
roomId: 'room@chatroom',
groupName: '产品群',
memberWxid: 'wxid_a',
memberName: '甲',
wechatName: '甲',
groupRemark: '',
contactRemark: '',
previousCount: 4,
currentCount: 3,
delta: -1,
message: '甲退出了产品群',
detectedAt: 1_727_000_000_000
}
]
}))
vi.mock('electron', () => ({
app: { getPath: () => fixture.root, getVersion: () => 'test-version' },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString()
}
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContacts: () => fixture.contacts,
listContactsAsync: async () => fixture.contacts,
listMessages: () => [],
getGroupSnapshot: () => null,
listRecentChat: () => [],
resolveMd5: () => null
}))
vi.mock('../../src/main/group-report-service', () => ({ exportGroupReport: vi.fn() }))
vi.mock('../../src/main/services/agent-group-report-service', () => ({
generateAgentGroupReport: vi.fn()
}))
vi.mock('../../src/main/services/agent-hub-service', () => ({
agentHubService: { getStatus: () => ({ connector: 'offline' }) }
}))
import { LocalAgentApiService } from '../../src/main/services/local-agent-api-service'
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
import type { GroupMemberStatsResult } from '../../src/shared/group-stats'
const TOKEN = 'T'.repeat(43)
const handles: HttpServerHandle[] = []
function createApi(): LocalAgentApiService {
const monitorState = fixture.state
return new LocalAgentApiService({
automationRuleStore: {
listRules: () => [],
getRule: () => undefined,
createRule: () => { throw new Error('not used') },
updateRule: () => undefined,
deleteRule: () => false,
setRuleEnabled: () => undefined
} as never,
automationExecutionLogService: { list: () => [] } as never,
listContacts: async () => fixture.contacts,
isDatabaseReady: () => true,
getVersion: () => 'test-version',
getPersonalWechatCapability: async () => ({
supported: true,
ready: false,
status: 'needs_binding',
capabilities: { text: false, image: false, voice: false }
} as never),
getAgentHubStatus: () => ({ connector: 'offline' }),
getGroupExitMonitorState: () => ({ ...monitorState }),
configureGroupExitMonitor: async (configuration) => {
if (configuration.monitoredRoomIds !== undefined) {
monitorState.monitoredRoomIds = [...configuration.monitoredRoomIds]
monitorState.monitoredGroupCount = monitorState.monitoredRoomIds.length
}
if (configuration.enabled !== undefined) monitorState.enabled = configuration.enabled
monitorState.running = monitorState.enabled
return { ...monitorState }
},
listGroupExitMonitorEvents: ({ roomId, sinceMs, untilMs, limit }) =>
fixture.events
.filter((event) => !roomId || event.roomId === roomId)
.filter((event) => sinceMs === undefined || event.detectedAt >= sinceMs)
.filter((event) => untilMs === undefined || event.detectedAt <= untilMs)
.sort((left, right) => left.detectedAt - right.detectedAt)
.slice(-limit!),
getGroupMemberStats: async (query): Promise<GroupMemberStatsResult> => ({
conversationId: query.userMd5,
startTime: query.startTime,
endTime: query.endTime,
freshness: 'stale',
complete: false,
memberCount: 2,
activeMemberCount: 1,
silentMemberCount: 1,
activeMembers: [{
senderId: 'wxid_a',
displayName: '甲',
groupNickname: '群甲',
messageCount: 3,
lastMessageTime: 1_728_000_000_000
}],
silentMembers: [{ senderId: 'wxid_b', displayName: '乙', groupNickname: '' }],
unattributedMessages: 4,
excludedSystemMessages: 5,
firstMessageTime: 1_727_000_000_000,
limitations: ['本地索引尚未完全同步,以下结果可能不完整。']
})
})
}
async function startServer(): Promise<HttpServerHandle> {
const handle = await startHttpServer('127.0.0.1', 0, {
tokenProvider: () => TOKEN,
agentApiService: createApi()
})
handles.push(handle)
return handle
}
async function request(
handle: HttpServerHandle,
pathname: string,
method = 'GET',
body?: unknown
): Promise<Response> {
return fetch(`http://${handle.host}:${handle.port}${pathname}`, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
},
...(body === undefined ? {} : { body: JSON.stringify(body) })
})
}
describe('Agent API Phase 2', () => {
afterEach(async () => {
await Promise.all(handles.splice(0).map((handle) => handle.close()))
})
afterAll(async () => {
await Promise.all(handles.splice(0).map((handle) => handle.close()))
})
it('advertises only the Phase 2 monitor and stats operations', async () => {
const handle = await startServer()
const response = await request(handle, '/api/v1/capabilities')
const body = await response.json()
expect(response.status).toBe(200)
expect(body.groupExitMonitor.operations).toEqual([
'read_state',
'configure_scope',
'enable',
'disable',
'list_events'
])
expect(body.groupStats.operations).toEqual(['member_stats'])
})
it('validates the complete monitor patch before changing scope', async () => {
const handle = await startServer()
const invalid = await request(handle, '/api/v1/monitors/group-exits', 'PATCH', {
enabled: false,
monitoredConversationIds: ['room@chatroom', 'missing@chatroom']
})
expect(invalid.status).toBe(422)
expect(fixture.state.enabled).toBe(true)
expect(fixture.state.monitoredRoomIds).toEqual(['room@chatroom'])
const updated = await request(handle, '/api/v1/monitors/group-exits', 'PATCH', {
enabled: false,
monitoredConversationIds: []
})
expect(updated.status).toBe(200)
expect(await updated.json()).toMatchObject({
enabled: false,
monitoredConversationIds: [],
eventCount: 2
})
})
it('returns stable event DTOs with time filters and ascending order', async () => {
const handle = await startServer()
const response = await request(
handle,
'/api/v1/monitors/group-exits/events?conversationId=room%40chatroom&since=2024-10-01T00%3A00%3A00%2B07%3A00&limit=1'
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toMatchObject({ count: 1 })
expect(body.events[0]).toMatchObject({
eventId: 'event-2',
conversationId: 'room@chatroom',
memberId: 'wxid_b',
detectedAt: new Date(1_728_000_000_000).toISOString()
})
expect(body.events[0].read).toBeUndefined()
})
it('adapts stable group IDs and preserves stats freshness diagnostics', async () => {
const handle = await startServer()
const response = await request(
handle,
'/api/v1/groups/room%40chatroom/member-stats?start=2026-10-01T00%3A00%3A00%2B07%3A00&end=2026-10-02T00%3A00%3A00%2B07%3A00'
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toMatchObject({
conversation: { id: 'room@chatroom', name: '产品群' },
conversationId: 'room@chatroom',
freshness: 'stale',
complete: false,
unattributedMessages: 4,
excludedSystemMessages: 5,
firstMessageAt: new Date(1_727_000_000_000).toISOString()
})
expect(body.activeMembers[0]).toMatchObject({
memberId: 'wxid_a',
lastMessageAt: new Date(1_728_000_000_000).toISOString()
})
const direct = await request(
handle,
'/api/v1/groups/wxid_friend/member-stats?start=2026-10-01T00%3A00%3A00%2B07%3A00&end=2026-10-02T00%3A00%3A00%2B07%3A00'
)
expect(direct.status).toBe(422)
expect((await direct.json()).error.code).toBe('NOT_GROUP_CONVERSATION')
})
})
@@ -38,7 +38,13 @@ function makeRoot(): string {
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
// Windows 上 sqlite 句柄未释放时 unlink 会 EBUSY;retry 吸收瞬时占用,
// 免得 teardown 抛错把真正的断言失败盖掉。
await Promise.all(
roots
.splice(0)
.map((root) => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))
)
})
/** 合法 PNG 头 + 唯一尾部:不同 seed → 不同内容身份(sha256)。 */
@@ -164,8 +170,14 @@ function harness(options: {
}
}
/**
* 等整遍 pass 结束。
*
* 必须显式给预算:这组用例里有 1050 张的 pass,Windows runner 比本机慢一个数量级,
* 默认 1000ms 会超时(其余 image-text-index 用例组也是显式 15s~60s)。
*/
const finishPass = async (service: ImageTextIndexService): Promise<void> => {
await vi.waitFor(() => expect(service.isRunning()).toBe(false))
await vi.waitFor(() => expect(service.isRunning()).toBe(false), { timeout: 60_000 })
}
describe('并发契约', () => {
@@ -491,7 +503,7 @@ describe('并发契约', () => {
})
await restarted.startPass()
await vi.waitFor(() => expect(restarted.isRunning()).toBe(false))
await vi.waitFor(() => expect(restarted.isRunning()).toBe(false), { timeout: 60_000 })
expect(recognize2).not.toHaveBeenCalled()
restarted.resetAccount()
@@ -262,6 +262,11 @@ describe('派生库句柄的账号身份缓存', () => {
h.service.resetAccount()
})
/**
* 显式预算(见结尾 `30_000`):本用例**故意**烧 ≥700ms 的 sleep,再叠两次真实 SQLite pass——
* 本机实测 ~3.9s,已吃掉 vitest 默认 5000ms 的约 80%(Windows 实测 5097ms 越线)。
* 内外预算本就该对齐:`runPass` 内部已在等 60_000ms,外层卡默认 5s 是漏写。
*/
it('pre-loop cost is excluded from per-image cost', async () => {
const PRE_LOOP_MS = 400
const withPreLoop = createHarness({
@@ -308,7 +313,7 @@ describe('派生库句柄的账号身份缓存', () => {
withPreLoop.service.resetAccount()
control.service.resetAccount()
})
}, 30_000)
})
/**
+645
View File
@@ -0,0 +1,645 @@
import fs from 'fs-extra'
import path from 'node:path'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const root = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('node:path') as typeof import('node:path')
return fs.mkdtempSync(path.join(os.tmpdir(), 'tracememo-agent-api-'))
})
vi.mock('electron', () => ({
app: { getPath: () => root, getVersion: () => 'test-version' },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
}
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContacts: () => [],
listContactsAsync: async () => [],
listMessages: () => [],
getGroupSnapshot: () => null,
listRecentChat: () => [],
resolveMd5: () => null
}))
vi.mock('../../src/main/group-report-service', () => ({ exportGroupReport: vi.fn() }))
vi.mock('../../src/main/services/agent-group-report-service', () => ({
generateAgentGroupReport: vi.fn()
}))
vi.mock('../../src/main/services/agent-hub-service', () => ({
agentHubService: {
getStatus: () => ({ connector: 'offline' }),
testSend: vi.fn()
}
}))
vi.mock('../../src/main/services/group-exit-monitor-service', () => ({
groupExitMonitorService: {
getState: () => ({ enabled: false, running: false, monitoredRoomIds: [] })
}
}))
import {
BUILTIN_DAILY_REPORT_RULE_ID,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
} from '../../src/shared/automation'
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
import type { Contact } from '../../src/shared/types'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
import { LocalAgentApiService } from '../../src/main/services/local-agent-api-service'
import {
apiServer,
startHttpServer,
type HttpServerHandle,
type HttpServerOptions
} from '../../src/main/http-server'
import type { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
const TOKEN = 'T'.repeat(43)
const handles: HttpServerHandle[] = []
type TestDailyDraft = {
name: string
ruleType: string
scope: string
conditions: {
requireMentionMe: boolean
keyword: string
keywordMatchMode: string
conversationIds: string[]
ignoreSelf: boolean
}
actions: Array<{ type: string; enabled: boolean; text?: string }>
cooldownSeconds: number
replyDelaySeconds: number
}
type TestScheduledDraft = {
name: string
ruleType: string
scheduledReport: {
schedule: { time: string }
report: {
sourceConversationId: string
range: string
messageTypes: string[]
templateId: string
memberNameMode: string
timeoutSeconds: number
}
target: { type: string; contactId: string }
postfixText: string
}
}
type TestLeaveNotificationDraft = {
name: string
ruleType: string
leaveNotification: {
target: { type: string }
template: string
notifyScope: string
notifyRoomIds: string[]
targetNeedsReview?: boolean
}
}
const contacts: Contact[] = [
{
m_nsUsrName: 'room@chatroom',
m_nsNickName: '产品群',
md5: 'group-md5',
type: 'group'
},
{
m_nsUsrName: 'wxid_friend',
wxid: 'wxid_friend',
m_nsNickName: 'Alice',
md5: 'friend-md5',
type: 'user'
}
]
let databaseReady = true
let personalReady = false
let currentStore: AutomationRuleStore
let currentExecutionLog: AutomationExecutionLogService
function createApiService(
store = currentStore,
executionLog = currentExecutionLog
): LocalAgentApiService {
const personal = {
supported: true,
ready: personalReady,
status: personalReady ? 'ready' : 'needs_binding',
capabilities: { text: personalReady, image: false, voice: false },
senderStatus: { executablePath: '/private/wechat/runtime' }
} as unknown as PersonalWechatSendCapability
return new LocalAgentApiService({
automationRuleStore: store,
automationExecutionLogService: executionLog,
listContacts: async () => contacts,
isDatabaseReady: () => databaseReady,
getVersion: () => 'test-version',
getPersonalWechatCapability: async () => personal,
getAgentHubStatus: () => ({ connector: 'offline' }),
getGroupExitMonitorState: () => ({ enabled: false, running: false, monitoredRoomIds: [] })
})
}
async function startServer(
api = createApiService(),
queryApiService?: HttpServerOptions['queryApiService']
): Promise<HttpServerHandle> {
const handle = await startHttpServer('127.0.0.1', 0, {
tokenProvider: () => TOKEN,
agentApiService: api,
queryApiService
})
handles.push(handle)
return handle
}
function url(handle: HttpServerHandle, pathName: string): string {
return `http://${handle.host}:${handle.port}${pathName}`
}
async function request(
handle: HttpServerHandle,
pathName: string,
method = 'GET',
body?: unknown,
headers: Record<string, string> = {}
): Promise<Response> {
return fetch(url(handle, pathName), {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers
},
...(body !== undefined ? { body: JSON.stringify(body) } : {})
})
}
const validDailyDraft = (): TestDailyDraft => ({
name: '产品群日报自动化',
ruleType: 'daily_report',
scope: 'group',
conditions: {
requireMentionMe: true,
keyword: '日报',
keywordMatchMode: 'contains',
conversationIds: ['room@chatroom'],
ignoreSelf: true
},
actions: [{ type: 'replyText', enabled: true, text: '收到' }],
cooldownSeconds: 60,
replyDelaySeconds: 0
})
const validScheduledDraft = (): TestScheduledDraft => ({
name: '产品群每日日报',
ruleType: 'scheduled_report',
scheduledReport: {
schedule: { time: '20:00' },
report: {
sourceConversationId: 'room@chatroom',
range: 'today',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'contact', contactId: 'wxid_friend' },
postfixText: '日报已生成'
}
})
const validLeaveNotificationDraft = (): TestLeaveNotificationDraft => ({
name: '退群通知',
ruleType: 'leave_notification',
leaveNotification: {
target: { type: 'file_transfer' },
template: GROUP_EXIT_NOTIFICATION_TEMPLATE,
notifyScope: 'all',
notifyRoomIds: []
}
})
describe('Local Agent API', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
databaseReady = true
personalReady = false
currentStore = new AutomationRuleStore({ userDataPath: () => root })
currentExecutionLog = new AutomationExecutionLogService({ userDataPath: () => root })
})
afterEach(async () => {
await Promise.all(handles.splice(0).map((handle) => handle.close()))
await apiServer.stop()
})
afterAll(() => fs.removeSync(root))
it('reports application version and separates supported from runtime availability', async () => {
const handle = await startServer()
let response = await request(handle, '/api/v1/capabilities')
expect(response.status).toBe(200)
let body = await response.json()
expect(body).toMatchObject({
version: 'test-version',
apiVersion: 'v1',
database: { ready: true },
query: { supported: true, available: true },
automations: {
supported: true,
available: true,
ruleTypes: ['daily_report', 'scheduled_report', 'leave_notification']
},
wechat: {
personal: { supported: true, available: false, status: 'needs_binding' },
ilink: { supported: true, available: false, status: 'offline' }
}
})
expect(JSON.stringify(body)).not.toMatch(/private\/wechat|executablePath|token|context_token/i)
databaseReady = false
response = await request(handle, '/api/v1/capabilities')
body = await response.json()
expect(body).toMatchObject({
database: { ready: false },
query: { supported: true, available: false, reason: 'database_not_ready' },
groupStats: { supported: true, available: false, reason: 'database_not_ready' }
})
})
it('uses the Agent error envelope for authorization, method, missing route, and size failures', async () => {
const handle = await startServer()
const unauthorized = await fetch(url(handle, '/api/v1/capabilities'))
expect(unauthorized.status).toBe(401)
expect(await unauthorized.json()).toMatchObject({
error: { code: 'UNAUTHORIZED' },
requestId: expect.any(String)
})
const wrongMethod = await request(handle, '/api/v1/capabilities', 'POST')
expect(wrongMethod.status).toBe(405)
expect(wrongMethod.headers.get('allow')).toBe('GET')
expect(await wrongMethod.json()).toMatchObject({
error: { code: 'METHOD_NOT_ALLOWED' },
requestId: expect.any(String)
})
const missingRoute = await request(handle, '/api/v1/automations/rule-1/run')
expect(missingRoute.status).toBe(404)
expect(await missingRoute.json()).toMatchObject({
error: { code: 'NOT_FOUND' },
requestId: expect.any(String)
})
const oversized = await fetch(url(handle, '/api/v1/automations'), {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify('x'.repeat(1024 * 1024 + 1))
})
expect(oversized.status).toBe(413)
expect(oversized.headers.get('x-request-id')).toEqual(expect.any(String))
expect(await oversized.json()).toMatchObject({
error: { code: 'PAYLOAD_TOO_LARGE' },
requestId: expect.any(String)
})
})
it('preserves the existing Query operation request and response contract', async () => {
const queryApi = {
messages: vi.fn(async (payload: unknown) => ({ status: 'completed', payload }))
} as unknown as LocalQueryApiService
const handle = await startServer(createApiService(), queryApi)
const payload = { target: { query: 'Alice' }, timeRange: { kind: 'all' } }
const response = await request(handle, '/api/v1/query/messages', 'POST', payload)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ status: 'completed', payload })
expect(queryApi.messages).toHaveBeenCalledWith(payload)
})
it('validates rule fields and resolves scheduled IDs without persisting', async () => {
const handle = await startServer()
const scheduled = validScheduledDraft()
const response = await request(handle, '/api/v1/automations/validate', 'POST', scheduled)
expect(response.status).toBe(200)
const result = await response.json()
expect(result).toMatchObject({ valid: true, normalized: { enabled: false } })
expect(result.normalized.scheduledReport.report.sourceConversationId).toBe('room@chatroom')
expect(result.normalized.scheduledReport.target.contactId).toBe('wxid_friend')
expect(result.effects).toMatchObject({ sendsWechatMessage: true })
expect(result.nextRunAt).toEqual(expect.any(String))
expect(currentStore.listRules().some((rule) => rule.name === '产品群每日日报')).toBe(false)
const mentionOnly = validDailyDraft()
mentionOnly.conditions.keyword = ''
const mentionOnlyResponse = await request(
handle,
'/api/v1/automations/validate',
'POST',
mentionOnly
)
expect((await mentionOnlyResponse.json()).valid).toBe(true)
const invalidEnum = validDailyDraft()
invalidEnum.conditions.keywordMatchMode = 'regex'
const enumResponse = await request(handle, '/api/v1/automations/validate', 'POST', invalidEnum)
const enumResult = await enumResponse.json()
expect(enumResult.valid).toBe(false)
expect(enumResult.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'conditions.keywordMatchMode', code: 'invalid_enum' })
])
)
const invalidTime = validScheduledDraft()
invalidTime.scheduledReport.schedule.time = '29:90'
const timeResponse = await request(handle, '/api/v1/automations/validate', 'POST', invalidTime)
expect((await timeResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'scheduledReport.schedule.time', code: 'invalid_time' })
])
)
const unknownFieldResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
...validDailyDraft(),
callbackUrl: 'http://127.0.0.1'
})
expect((await unknownFieldResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'draft.callbackUrl', code: 'unknown_field' })
])
)
const invalidRuleTypeResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
...validDailyDraft(),
ruleType: 'webhook'
})
expect((await invalidRuleTypeResponse.json()).issues).toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'ruleType', code: 'invalid_enum' })])
)
const enabledInputResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
...validDailyDraft(),
enabled: true
})
expect((await enabledInputResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'enabled', code: 'use_enable_operation' })
])
)
const invalidAllScopeRoomResponse = await request(
handle,
'/api/v1/automations/validate',
'POST',
{
...validLeaveNotificationDraft(),
leaveNotification: {
...validLeaveNotificationDraft().leaveNotification,
notifyRoomIds: ['missing-room@chatroom']
}
}
)
expect((await invalidAllScopeRoomResponse.json()).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'leaveNotification.notifyRoomIds[0]',
code: 'contact_not_found'
})
])
)
})
it('creates disabled rules, updates them, validates on enable, and protects system rules', async () => {
const handle = await startServer()
const createResponse = await request(handle, '/api/v1/automations', 'POST', validDailyDraft(), {
'X-Request-Id': 'agent:create-rule'
})
expect(createResponse.status).toBe(201)
expect(createResponse.headers.get('x-request-id')).toBe('agent:create-rule')
const created = (await createResponse.json()).rule
expect(created).toMatchObject({ enabled: false, ruleType: 'daily_report' })
expect(created.conditions.conversationIds).toEqual(['room@chatroom'])
const getResponse = await request(handle, `/api/v1/automations/${created.id}`)
expect(getResponse.status).toBe(200)
expect((await getResponse.json()).rule.id).toBe(created.id)
const patchResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
name: '更新后的名称'
})
expect((await patchResponse.json()).rule).toMatchObject({
id: created.id,
name: '更新后的名称',
enabled: false
})
const immutableResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
ruleType: 'scheduled_report'
})
expect(immutableResponse.status).toBe(400)
expect(await immutableResponse.json()).toMatchObject({
error: { code: 'RULE_TYPE_IMMUTABLE' },
requestId: expect.any(String)
})
const metadataResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
createdAt: 0
})
expect(metadataResponse.status).toBe(400)
expect((await metadataResponse.json()).error.code).toBe('IMMUTABLE_FIELD')
databaseReady = false
const unavailableEnableResponse = await request(
handle,
`/api/v1/automations/${created.id}/enable`,
'POST'
)
expect(unavailableEnableResponse.status).toBe(409)
expect((await unavailableEnableResponse.json()).error.code).toBe('VALIDATION_FAILED')
expect(currentStore.getRule(created.id)?.enabled).toBe(false)
databaseReady = true
const enableResponse = await request(handle, `/api/v1/automations/${created.id}/enable`, 'POST')
expect((await enableResponse.json()).rule.enabled).toBe(true)
const disableResponse = await request(
handle,
`/api/v1/automations/${created.id}/disable`,
'POST'
)
expect((await disableResponse.json()).rule.enabled).toBe(false)
const filteredResponse = await request(
handle,
'/api/v1/automations?type=daily_report&enabled=false'
)
const filtered = await filteredResponse.json()
expect(filtered.rules.some((rule: { id: string }) => rule.id === created.id)).toBe(true)
const protectedDaily = await request(
handle,
`/api/v1/automations/${BUILTIN_DAILY_REPORT_RULE_ID}`,
'DELETE'
)
expect(protectedDaily.status).toBe(409)
expect((await protectedDaily.json()).error.code).toBe('PROTECTED_RULE')
const protectedLeave = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'DELETE'
)
expect(protectedLeave.status).toBe(409)
const nonBuiltinLeave = currentStore.createRule(validLeaveNotificationDraft())
const protectedNonBuiltinLeave = await request(
handle,
`/api/v1/automations/${nonBuiltinLeave.id}`,
'DELETE'
)
expect(protectedNonBuiltinLeave.status).toBe(409)
expect((await protectedNonBuiltinLeave.json()).error.code).toBe('PROTECTED_RULE')
const duplicateLeave = await request(handle, '/api/v1/automations', 'POST', {
name: '第二条退群通知',
ruleType: 'leave_notification',
leaveNotification: {
target: { type: 'file_transfer' },
template: '{groupName} {user}',
notifyScope: 'all',
notifyRoomIds: []
}
})
expect(duplicateLeave.status).toBe(409)
expect((await duplicateLeave.json()).error.code).toBe('SINGLETON_RULE')
const deleteCustom = await request(handle, `/api/v1/automations/${created.id}`, 'DELETE')
expect(deleteCustom.status).toBe(200)
expect((await deleteCustom.json()).deletedId).toBe(created.id)
})
it('lists bounded execution records with structured filters', async () => {
const handle = await startServer()
currentExecutionLog.record({
executionId: 'execution-1',
ruleId: BUILTIN_DAILY_REPORT_RULE_ID,
ruleName: '内置日报',
triggerTime: Date.parse('2025-01-02T03:04:05.000Z'),
trigger: 'message',
sourceDisplayName: '产品群',
status: 'failed',
durationMs: 1_250,
steps: [],
errorSummary: '日报生成失败'
})
const response = await request(
handle,
'/api/v1/automations/executions?ruleId=builtin-mention-me-daily-report&status=failed&since=2025-01-01T00%3A00%3A00Z&until=2025-01-03T00%3A00%3A00Z&limit=10'
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
count: 1,
executions: [
{
executionId: 'execution-1',
ruleId: BUILTIN_DAILY_REPORT_RULE_ID,
ruleType: 'daily_report',
trigger: 'message',
status: 'failed',
startedAt: '2025-01-02T03:04:05.000Z',
finishedAt: '2025-01-02T03:04:06.250Z',
error: '日报生成失败'
}
]
})
const invalidLimit = await request(handle, '/api/v1/automations/executions?limit=201')
expect(invalidLimit.status).toBe(400)
expect((await invalidLimit.json()).error.code).toBe('INVALID_ARGUMENT')
})
it('only clears target review when a valid replacement target is supplied', async () => {
const handle = await startServer()
currentStore.saveLeaveNotificationRule({
...validLeaveNotificationDraft(),
enabled: false,
leaveNotification: {
...validLeaveNotificationDraft().leaveNotification,
targetNeedsReview: true
}
})
const bypass = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'PATCH',
{ leaveNotification: { targetNeedsReview: false } }
)
expect(bypass.status).toBe(400)
expect((await bypass.json()).error.code).toBe('IMMUTABLE_FIELD')
expect(
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
const invalidTarget = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'PATCH',
{ leaveNotification: { target: {} } }
)
expect(invalidTarget.status).toBe(422)
expect(
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
const updated = await request(
handle,
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
'PATCH',
{ leaveNotification: { target: { type: 'self' } } }
)
expect(updated.status).toBe(200)
expect((await updated.json()).rule.leaveNotification).not.toHaveProperty('targetNeedsReview')
expect(
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.target
).toEqual({ type: 'self' })
})
it('returns a standard persistence error and rolls back the in-memory rule on write failure', async () => {
const blockedPath = path.join(root, 'not-a-directory')
fs.writeFileSync(blockedPath, 'file')
const failingStore = new AutomationRuleStore({ userDataPath: () => blockedPath })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const handle = await startServer(createApiService(failingStore, currentExecutionLog))
try {
const response = await request(handle, '/api/v1/automations', 'POST', {
...validDailyDraft(),
name: '不得假成功'
})
expect(response.status).toBe(500)
expect(await response.json()).toMatchObject({
error: { code: 'PERSISTENCE_FAILED' },
requestId: expect.any(String)
})
expect(failingStore.listRules().some((rule) => rule.name === '不得假成功')).toBe(false)
} finally {
warn.mockRestore()
}
})
})
+83 -5
View File
@@ -29,7 +29,7 @@ const fixture = vi.hoisted(() => ({
}))
vi.mock('electron', () => ({
app: { getPath: () => fixture.root },
app: { getPath: () => fixture.root, getVersion: () => 'test-version' },
safeStorage: {
isEncryptionAvailable: () => fixture.storageAvailable,
encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'),
@@ -111,7 +111,7 @@ describe('Local API authentication', () => {
const health = await fetch(`${baseUrl(handle)}/api/v1/health`)
expect(health.status).toBe(200)
const healthBody = await health.json()
expect(healthBody).toMatchObject({ ok: true, service: 'TraceMemo Reader' })
expect(healthBody).toMatchObject({ ok: true, service: 'TraceMemo Reader', version: 'test-version' })
expect(JSON.stringify(healthBody)).not.toMatch(
/token|authorization|wxid|databasePath|provider/i
)
@@ -128,6 +128,64 @@ describe('Local API authentication', () => {
await expect(response.json()).resolves.toMatchObject({ count: 1 })
})
it.each([
['POST', '/api/v1/health', 'GET'],
['PATCH', '/api/v1/current_time', 'GET'],
['POST', '/api/v1/contact', 'GET'],
['POST', '/api/v1/chatroom', 'GET'],
['POST', '/api/v1/recent_chat', 'GET'],
['POST', '/api/v1/chatlog', 'GET'],
['POST', '/api/v1/group_snapshot', 'GET'],
['POST', '/api/v1/resolve', 'GET'],
['POST', '/api/v1/agent/status', 'GET'],
['GET', '/api/v1/report', 'POST'],
['PATCH', '/api/v1/query/capabilities', 'GET'],
['GET', '/api/v1/query/messages', 'POST'],
['POST', '/api/v1/media/image-1', 'GET, HEAD']
])('rejects unsupported methods with Allow: %s', async (method, pathname, allow) => {
const handle = await startFixtureServer()
const requestId = 'phase1:method-guard'
const response = await fetch(`${baseUrl(handle)}${pathname}`, {
method,
headers: { 'X-Request-Id': requestId }
})
expect(response.status).toBe(405)
expect(response.headers.get('allow')).toBe(allow)
expect(response.headers.get('x-request-id')).toBe(requestId)
await expect(response.json()).resolves.toMatchObject({ requestId })
})
it('rejects oversized JSON request bodies with 413 and a request ID', async () => {
const handle = await startFixtureServer()
const requestId = 'phase1:body-limit'
const response = await fetch(`${baseUrl(handle)}/api/v1/report`, {
method: 'POST',
headers: {
Authorization: `Bearer ${VALID_TOKEN}`,
'Content-Type': 'application/json',
'X-Request-Id': requestId
},
body: 'x'.repeat(1024 * 1024 + 1)
})
expect(response.status).toBe(413)
expect(response.headers.get('x-request-id')).toBe(requestId)
await expect(response.json()).resolves.toMatchObject({ status: 413, requestId })
})
it('replaces malformed or oversized client request IDs with a server UUID', async () => {
const handle = await startFixtureServer()
const malformedId = 'x'.repeat(129)
const response = await fetch(`${baseUrl(handle)}/api/v1/health`, {
headers: { 'X-Request-Id': malformedId }
})
expect(response.status).toBe(200)
expect(response.headers.get('x-request-id')).toMatch(/^[0-9a-f-]{36}$/i)
expect(response.headers.get('x-request-id')).not.toBe(malformedId)
})
it('exposes the query capability catalog only with the existing bearer token', async () => {
const handle = await startFixtureServer()
const path = `${baseUrl(handle)}/api/v1/query/capabilities`
@@ -181,6 +239,23 @@ describe('Local API authentication', () => {
expect(JSON.stringify(response.headers)).not.toMatch(/path|token|database/i)
})
it('preserves authenticated HEAD requests for media without returning a body', async () => {
const provider = vi.fn(async () => ({
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
mimeType: 'image/jpeg'
}))
const handle = await startFixtureServer(() => VALID_TOKEN, provider)
const response = await fetch(`${baseUrl(handle)}/api/v1/media/message-1`, {
method: 'HEAD',
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
})
expect(response.status).toBe(200)
expect(response.headers.get('content-length')).toBe('4')
expect((await response.arrayBuffer()).byteLength).toBe(0)
expect(provider).toHaveBeenCalledOnce()
})
it('adds media metadata to chatlog while redacting image keys', async () => {
const handle = await startFixtureServer()
const response = await fetch(`${baseUrl(handle)}/api/v1/chatlog?talker=测试联系人`, {
@@ -265,9 +340,10 @@ describe('Local API authentication', () => {
headers: { Authorization: authorization }
})
expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({
await expect(response.json()).resolves.toMatchObject({
error: 'unauthorized',
message: 'Valid API token required'
message: 'Valid API token required',
requestId: expect.any(String)
})
}
)
@@ -344,7 +420,9 @@ describe('Local API authentication', () => {
expect(response.headers.get('access-control-allow-origin')).toBe(origin)
expect(response.headers.get('access-control-allow-methods')).toContain('PATCH')
expect(response.headers.get('access-control-allow-methods')).toContain('DELETE')
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization')
expect(response.headers.get('access-control-allow-headers')).toBe(
'Content-Type, Authorization, X-Request-Id'
)
})
it.each([
+4 -1
View File
@@ -118,8 +118,11 @@ describe('preload IPC contract', () => {
createTime: 1785553200,
svrId: 'server-11'
}
// preload 把 options 一路透传(不传即显式 undefined),主进程签名是 (reference, options?)。
await api.recognizeVoice(voiceReference)
expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference)
expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference, undefined)
await api.recognizeVoice(voiceReference, { force: true })
expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference, { force: true })
await api.cancelVoiceRecognition(voiceReference)
expect(invoke).toHaveBeenLastCalledWith('voice:cancelRecognition', voiceReference)
await api.downloadVoiceModel()
@@ -38,6 +38,7 @@ vi.mock('electron', () => ({
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContacts: () => fixture.contacts,
listContactsAsync: async () => fixture.contacts,
listMessages: () => [],
getGroupSnapshot: () => null,
listRecentChat: () => [],
+20 -3
View File
@@ -38,8 +38,25 @@ const expectContainsTokens = (text: string, tokens: string[]): void => {
const capability = await systemOcrService.getCapability()
const onWindows = process.platform === 'win32'
const nativeAvailable = onWindows && capability.available
const platformGate = onWindows ? it : it.skip
const nativeGate = onWindows && capability.available ? it : it.skip
const nativeGate = nativeAvailable ? it : it.skip
/**
* Windows 引擎是**按语言包**建识别器的:机器上只有 en-US 时能认英文、认不出中文。
* 所以「中文 token」只在确认存在中文识别器时才算硬门槛 —— CI 的 Windows runner
* 只装了 en-US,中文识别不能当所有 CI 的硬门槛(文件头的约定)。
*
* `language` 为 null 表示"跟随系统语言",无法据以判定,按不可用处理(宁可跳过不可误红)。
*/
const nativeChineseGate = nativeAvailable && /^zh/i.test(capability.language ?? '') ? it : it.skip
if (nativeAvailable && !/^zh/i.test(capability.language ?? '')) {
console.warn(
`[integration] Windows OCR 中文用例跳过:识别器语言为「${capability.language ?? '跟随系统语言'}」,` +
'本机未安装中文 OCR 语言包。中文用例需在装有 zh-Hans-CN 识别器的 Windows 机器上通过。'
)
}
describe('Windows System OCR native fidelity', () => {
platformGate('reports a usable capability on this machine', () => {
@@ -49,7 +66,7 @@ describe('Windows System OCR native fidelity', () => {
}
})
nativeGate('recognizes simplified Chinese text', async () => {
nativeChineseGate('recognizes simplified Chinese text', async () => {
const result = await systemOcrService.recognize({
imageDataUrl: toDataUrl('system-ocr-zh.png', 'image/png')
})
@@ -72,7 +89,7 @@ describe('Windows System OCR native fidelity', () => {
expectContainsTokens(result.text, ['TraceMemo', 'System', 'OCR'])
})
nativeGate('recognizes mixed Chinese/English text', async () => {
nativeChineseGate('recognizes mixed Chinese/English text', async () => {
const result = await systemOcrService.recognize({
imageDataUrl: toDataUrl('system-ocr-mixed.png', 'image/png')
})
+161
View File
@@ -0,0 +1,161 @@
import { createRequire } from 'module'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, describe, expect, it } from 'vitest'
const nodeRequire = createRequire(import.meta.url)
const { findNestedMacosCodePaths, signMacosAppBundle } = nodeRequire(
'../../scripts/after-pack.cjs'
) as {
findNestedMacosCodePaths: (appBundlePath: string) => string[]
signMacosAppBundle: (appBundlePath: string, run?: (args: string[]) => void) => string
}
const root = mkdtempSync(join(tmpdir(), 'wxe-after-pack-sign-'))
/** 最小可用的 64 位 thin Mach-O 头,足以让 readBinaryArchitectures 判出 x64。 */
function macho(): Buffer {
const buffer = Buffer.alloc(32)
buffer.writeUInt32LE(0xfeedfacf, 0)
buffer.writeUInt32LE(0x01000007, 4)
return buffer
}
function file(...segments: string[]): string {
const target = join(root, ...segments)
mkdirSync(join(target, '..'), { recursive: true })
writeFileSync(target, macho())
return target
}
/** 复刻 Electron 43.1.0 darwin-x64 的未签名 bundle 形状。 */
function fixtureApp(): string {
const app = join(root, 'TraceMemo.app')
file('TraceMemo.app', 'Contents', 'MacOS', 'TraceMemo')
file('TraceMemo.app', 'Contents', 'Frameworks', 'Mantle.framework', 'Versions', 'A', 'Mantle')
file(
'TraceMemo.app',
'Contents',
'Frameworks',
'Electron Framework.framework',
'Versions',
'A',
'Electron Framework'
)
file(
'TraceMemo.app',
'Contents',
'Frameworks',
'Electron Framework.framework',
'Versions',
'A',
'Libraries',
'libffmpeg.dylib'
)
file(
'TraceMemo.app',
'Contents',
'Frameworks',
'Electron Framework.framework',
'Versions',
'A',
'Helpers',
'chrome_crashpad_handler'
)
file('TraceMemo.app', 'Contents', 'Frameworks', 'Helper.app', 'Contents', 'MacOS', 'Helper')
// framework 内指向 Versions/A 的符号链接:真实文件在更深层级被走到,这里要跳过。
symlinkSync(
'A',
join(
root,
'TraceMemo.app',
'Contents',
'Frameworks',
'Mantle.framework',
'Versions',
'Current'
),
'dir'
)
// Contents/Resources 下的原生文件不是「嵌套代码」,不参与签名。
file(
'TraceMemo.app',
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules',
'sherpa-onnx-darwin-x64',
'sherpa-onnx.node'
)
// 非原生文件必须被忽略。
writeFileSync(join(root, 'TraceMemo.app', 'Contents', 'Frameworks', 'README.md'), 'not a binary')
return app
}
const app = fixtureApp()
const relative = (target: string): string => target.slice(app.length + 1)
describe('macOS nested code signing order', () => {
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('signs nested code inside-out and ignores resources and symlinks', () => {
const paths = findNestedMacosCodePaths(app).map(relative)
expect(paths).toContain(join('Contents', 'MacOS', 'TraceMemo'))
expect(paths).not.toContain(app)
expect(paths.some((entry) => entry.includes('Resources'))).toBe(false)
expect(paths.some((entry) => entry.endsWith('.md'))).toBe(false)
expect(paths.some((entry) => entry.includes('Versions/Current'))).toBe(false)
const index = (needle: string): number => paths.indexOf(needle)
const handler =
'Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/chrome_crashpad_handler'
const frameworkBinary =
'Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework'
const framework = 'Contents/Frameworks/Electron Framework.framework'
const helperBinary = 'Contents/Frameworks/Helper.app/Contents/MacOS/Helper'
const helperApp = 'Contents/Frameworks/Helper.app'
expect(index(handler)).toBeGreaterThanOrEqual(0)
// 内层可执行文件先于其所属 bundle,helper 的可执行文件先于 helper app。
expect(index(handler)).toBeLessThan(index(framework))
expect(index(frameworkBinary)).toBeLessThan(index(framework))
expect(index(helperBinary)).toBeLessThan(index(helperApp))
// 最深的目标排在最前。
expect(paths[0]).toBe(handler)
})
it('re-signs the app bundle after every nested target', () => {
const calls: string[][] = []
let verifyCalls = 0
const run = (args: string[]): void => {
if (args[0] === '--verify') {
verifyCalls += 1
// 未签名来源包:外层首次校验必然失败,补签之后才允许通过。
if (verifyCalls === 1) throw new Error('code object is not signed at all')
return
}
calls.push(args)
}
signMacosAppBundle(app, run)
const signed = calls.map((args) => args[args.length - 1])
expect(signed[signed.length - 1]).toBe(app)
expect(signed.slice(0, -1)).toEqual(findNestedMacosCodePaths(app))
expect(verifyCalls).toBe(2)
})
it('leaves an already valid bundle untouched', () => {
const calls: string[][] = []
const run = (args: string[]): void => {
calls.push(args)
}
signMacosAppBundle(app, run)
// 只有首次 --verify,没有任何 --sign。
expect(calls).toEqual([['--verify', '--strict', app]])
})
})
@@ -55,7 +55,10 @@ describe('AgentHubConversationStore', () => {
const conversation = store.get('user-a')
expect(conversation?.messages.map((item) => item.direction)).toEqual(['in', 'out'])
// Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。
if (process.platform !== 'win32') {
expect(statSync(filePath).mode & 0o777).toBe(0o600)
}
})
it('保留发送失败的记录与错误码,便于在界面上对账', () => {
@@ -47,6 +47,16 @@ const readyCapability: PersonalWechatSendCapability = {
const NOW = 1_700_000_000_000
/** 本地时区的 `YYYY-MM-DD HH:mm:ss`:`{time}` 按本地时区渲染,写死偏移量只在东八区通过。 */
function localStamp(epochMs: number): string {
const date = new Date(epochMs)
const pad = (value: number): string => String(value).padStart(2, '0')
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
)
}
function exitEvent(overrides: Partial<GroupMemberExitedEvent> = {}): GroupMemberExitedEvent {
return {
eventId: 'room@chatroom:wxid_member:1700000000000:1',
@@ -338,7 +348,10 @@ describe('AutomationService.handleGroupExit', () => {
it('发送能力缺失 → execution failed(退群事实不受影响)', async () => {
const harness = createHarness()
harness.setCapability({ ...readyCapability, capabilities: { text: false, image: false, voice: false } })
harness.setCapability({
...readyCapability,
capabilities: { text: false, image: false, voice: false }
})
await harness.service.handleGroupExit(exitEvent())
@@ -395,7 +408,7 @@ describe('AutomationService.handleGroupExit', () => {
if (content.type === 'text') {
// 群名({groupName})与成员群昵称({groupRemark})是两个值,不能混。
expect(content.text).toBe(
'产品测试群|微信昵称|群内备注|wxid_member|243->242|2023-11-15 06:13:20'
`产品测试群|微信昵称|群内备注|wxid_member|243->242|${localStamp(NOW)}`
)
}
})
+226 -1
View File
@@ -22,18 +22,26 @@ const { hasWindowsSherpaRuntime } = nodeRequire('../../scripts/prepare-win-runti
const {
validateAsarRuntimeDependencies,
validateFfmpegRuntime,
validateKoffiRuntime,
validateReaderSkillRuntime,
validateSherpaRuntime,
validateSilkWasmRuntime,
validateSystemOcrRuntime,
findMacosHelperPaths,
signMacosHelpers,
signMacosAppBundle
} = nodeRequire('../../scripts/after-pack.cjs') as {
validateAsarRuntimeDependencies: (runtimeResources: string) => void
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
validateKoffiRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
validateReaderSkillRuntime: (runtimeResources: string) => string
validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
validateSilkWasmRuntime: (runtimeResources: string) => void
validateSystemOcrRuntime: (
runtimeResources: string,
platform: NodeJS.Platform,
arch: string
) => void
findMacosHelperPaths: (runtimeResources: string) => string[]
signMacosHelpers: (runtimeResources: string, run?: CodesignRunner) => string[]
signMacosAppBundle: (appBundlePath: string, run?: CodesignRunner) => string
@@ -60,7 +68,10 @@ const {
pruneIntelMacKeyTool,
pruneForeignArchConnectors,
pruneForeignArchNativeRuntimes,
validateRuntimeBinaryArchitecture
validateRuntimeBinaryArchitecture,
findSendRuntime,
enforceSendRuntimeBoundary,
default: afterPack
} = nodeRequire('../../scripts/after-pack.cjs') as {
pruneIntelMacKeyTool: (
runtimeResources: string,
@@ -83,6 +94,20 @@ const {
arch: string,
label: string
) => void
findSendRuntime: (runtimeResources: string) => string | null
enforceSendRuntimeBoundary: (
runtimeResources: string,
platform: NodeJS.Platform,
bundlesSendRuntime?: boolean
) => string | null
default: (context: AfterPackContext) => Promise<void>
}
type AfterPackContext = {
appOutDir: string
electronPlatformName: NodeJS.Platform
arch?: number
packager: { appInfo: { productFilename: string } }
}
const { readBinaryArchitectures } = nodeRequire('../../scripts/binary-arch.cjs') as {
readBinaryArchitectures: (filePath: string) => string[]
@@ -248,6 +273,91 @@ describe('production runtime packaging', () => {
expect(config).toContain('node_modules/sherpa-onnx-*/**')
})
it('requires the matching System OCR native runtime', () => {
const resources = join(root, 'system-ocr-resources')
const modules = join(resources, 'app.asar.unpacked', 'node_modules', '@napi-rs')
const base = join(modules, 'system-ocr')
expect(() => validateSystemOcrRuntime(resources, 'darwin', 'arm64')).toThrow(
/Missing unpacked System OCR runtime:.*system-ocr/
)
mkdirSync(base, { recursive: true })
writeFileSync(join(base, 'package.json'), '{}')
writeFileSync(join(base, 'index.js'), 'module.exports = {}')
const mac = join(modules, 'system-ocr-darwin-arm64')
mkdirSync(mac, { recursive: true })
writeFileSync(join(mac, 'package.json'), '{}')
writeFileSync(join(mac, 'system-ocr.darwin-arm64.node'), 'fixture')
expect(() => validateSystemOcrRuntime(resources, 'darwin', 'arm64')).not.toThrow()
// Windows 的原生包名带 -msvc 后缀,查找规则必须跟着改。
expect(() => validateSystemOcrRuntime(resources, 'win32', 'x64')).toThrow(/win32-x64-msvc/)
const windows = join(modules, 'system-ocr-win32-x64-msvc')
mkdirSync(windows, { recursive: true })
writeFileSync(join(windows, 'package.json'), '{}')
writeFileSync(join(windows, 'system-ocr.win32-x64-msvc.node'), 'fixture')
expect(() => validateSystemOcrRuntime(resources, 'win32', 'x64')).not.toThrow()
// Linux 不是 supported target,不应做硬校验。
expect(() => validateSystemOcrRuntime(resources, 'linux', 'x64')).not.toThrow()
})
it('requires the koffi native module for the packaged platform', () => {
const resources = join(root, 'koffi-resources')
const modules = join(resources, 'app.asar.unpacked', 'node_modules', '@koromix')
expect(() => validateKoffiRuntime(resources, 'darwin', 'x64')).toThrow(
/Missing macOS Koffi native module:.*koffi-darwin-x64/
)
expect(() => validateKoffiRuntime(resources, 'darwin', 'arm64')).toThrow(
/koffi-darwin-arm64[/\\]darwin_arm64[/\\]koffi\.node/
)
expect(() => validateKoffiRuntime(resources, 'win32', 'x64')).toThrow(
/Missing Windows Koffi native module:.*koffi-win32-x64/
)
// koffi 运行期按 `${platform}-${arch}` 拼目录名,darwin 用 darwin_<arch>,
// win32 用 win32_x64(见 node_modules/koffi/src/koffi/index.cjs)。
for (const segments of [
['koffi-darwin-x64', 'darwin_x64'],
['koffi-darwin-arm64', 'darwin_arm64'],
['koffi-win32-x64', 'win32_x64']
]) {
const nativeDirectory = join(modules, ...segments)
mkdirSync(nativeDirectory, { recursive: true })
writeFileSync(join(nativeDirectory, 'koffi.node'), 'fixture')
}
expect(() => validateKoffiRuntime(resources, 'darwin', 'x64')).not.toThrow()
expect(() => validateKoffiRuntime(resources, 'darwin', 'arm64')).not.toThrow()
expect(() => validateKoffiRuntime(resources, 'win32', 'x64')).not.toThrow()
// 没有对应原生包的组合应静默跳过,而不是误报。
expect(() => validateKoffiRuntime(resources, 'linux', 'x64')).not.toThrow()
expect(() => validateKoffiRuntime(resources, 'win32', 'arm64')).not.toThrow()
})
it('declares the cross-arch native runtimes pnpm 7 would otherwise skip', () => {
const packageJson = JSON.parse(
readFileSync(resolve(__dirname, '../../package.json'), 'utf8')
) as { dependencies: Record<string, string> }
// pnpm 7.33.7 不支持 supportedArchitectures,非宿主平台的可选依赖会被静默跳过,
// 而这些原生包必须在 dependencies 里显式声明,否则打包阶段才在 afterPack 报缺。
for (const name of [
'sherpa-onnx-darwin-x64',
'sherpa-onnx-win-x64',
'@napi-rs/system-ocr-darwin-x64',
'@napi-rs/system-ocr-win32-x64-msvc',
'@koromix/koffi-darwin-x64',
'@koromix/koffi-win32-x64'
]) {
expect(packageJson.dependencies).toHaveProperty(name)
}
})
it('finds only the macOS helpers that exist in packaged resources', () => {
const resources = join(root, 'helper-detect-resources', 'resources')
mkdirSync(resources, { recursive: true })
@@ -435,6 +545,121 @@ describe('per-architecture macOS packaging', () => {
})
})
describe('send runtime packaging boundary', () => {
const boundaryRoot = mkdtempSync(join(tmpdir(), 'wxe-send-runtime-'))
afterAll(() => rmSync(boundaryRoot, { recursive: true, force: true }))
function resourcesWithSendRuntime(name: string, unpacked = false): string {
const resources = join(boundaryRoot, name, 'Contents', 'Resources')
const runtime = unpacked
? join(resources, 'app.asar.unpacked', 'resources', 'runtime', 'darwin-arm64')
: join(resources, 'resources', 'runtime', 'darwin-arm64')
mkdirSync(runtime, { recursive: true })
writeFileSync(join(runtime, 'tm-wechat-host'), 'fixture')
return resources
}
it('accepts a macOS bundle without the send runtime', () => {
const resources = join(boundaryRoot, 'clean', 'Contents', 'Resources')
mkdirSync(resources, { recursive: true })
expect(findSendRuntime(resources)).toBeNull()
expect(enforceSendRuntimeBoundary(resources, 'darwin', false)).toBeNull()
})
it('fails a bundle that still carries the send runtime', () => {
expect(() =>
enforceSendRuntimeBoundary(resourcesWithSendRuntime('dirty'), 'darwin', false)
).toThrow(/must not include the WeChat send runtime/)
})
it('detects the send runtime when it lands under app.asar.unpacked', () => {
const resources = resourcesWithSendRuntime('unpacked', true)
expect(findSendRuntime(resources)).toBe(
join(resources, 'app.asar.unpacked', 'resources', 'runtime', 'darwin-arm64')
)
expect(() => enforceSendRuntimeBoundary(resources, 'darwin', false)).toThrow(
/must not include the WeChat send runtime/
)
})
it('requires the send runtime when the build is configured to bundle it', () => {
const missing = join(boundaryRoot, 'runtime-missing', 'Contents', 'Resources')
mkdirSync(missing, { recursive: true })
expect(() => enforceSendRuntimeBoundary(missing, 'darwin', true)).toThrow(
/requires the WeChat send runtime/
)
const present = resourcesWithSendRuntime('runtime-ok')
expect(enforceSendRuntimeBoundary(present, 'darwin', true)).toBe(
join(present, 'resources', 'runtime', 'darwin-arm64')
)
})
it('ignores non-macOS bundles', () => {
const resources = resourcesWithSendRuntime('win32')
expect(enforceSendRuntimeBoundary(resources, 'win32', false)).toBeNull()
expect(enforceSendRuntimeBoundary(resources, 'win32', true)).toBeNull()
})
it('keeps the send runtime build off the release publishing path', () => {
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')) as {
scripts: Record<string, string>
}
expect(Object.keys(pkg.scripts).filter((name) => name.includes('send-runtime'))).toEqual([
'build:mac:arm64:send-runtime'
])
expect(pkg.scripts['build:mac:arm64:send-runtime']).toContain('TM_SEND_RUNTIME_BUILD=1')
expect(pkg.scripts['build:mac:arm64:send-runtime']).toContain(
'electron-builder.send-runtime.yml'
)
expect(pkg.scripts['build:mac:arm64:send-runtime']).not.toContain('--publish')
for (const [name, command] of Object.entries(pkg.scripts)) {
if (!name.startsWith('release')) continue
expect(command).not.toContain('electron-builder.send-runtime.yml')
expect(command).not.toContain('TM_SEND_RUNTIME_BUILD')
}
})
it('excludes the send runtime in the default builder config', () => {
const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8')
expect(config).toContain("'!runtime/darwin-arm64/**'")
})
// 只测函数是测不出「边界校验有没有被接上去」的。这个 bundle 故意不含 app.asar 与
// Reader Skill:一旦 afterPack 把边界校验排到其它校验之后,抛出的就会是
// "Missing packaged application archive" 而不是边界错误,用例即失败。
it('applies the boundary check inside afterPack ahead of every other validation', async () => {
const appOutDir = join(boundaryRoot, 'wired')
const runtime = join(
appOutDir,
'TraceMemo.app',
'Contents',
'Resources',
'resources',
'runtime',
'darwin-arm64'
)
mkdirSync(runtime, { recursive: true })
writeFileSync(join(runtime, 'tm-wechat-host'), 'fixture')
await expect(
afterPack({
appOutDir,
electronPlatformName: 'darwin',
arch: 3,
packager: { appInfo: { productFilename: 'TraceMemo' } }
})
).rejects.toThrow(/must not include the WeChat send runtime/)
})
})
describe('release publishing policy', () => {
it('uploads GitHub releases as drafts until the notes are reviewed', () => {
for (const configFile of ['electron-builder.yml', 'electron-builder.win.yml']) {
+25 -31
View File
@@ -36,15 +36,15 @@ const onlineAgentHubStatus = (): AgentHubStatus => ({
})
const SCHEDULE_TIME = '18:30'
/** 按本地时区构造 ISO 串:调度走 `Date#setHours`,写死偏移量会让用例只在东八区通过。 */
const localISO = (year: number, month: number, day: number, hour: number, minute: number): string =>
new Date(year, month - 1, day, hour, minute, 0, 0).toISOString()
/** 一个"当天 18:30 之后"的时刻,保证当日槽位已到点。 */
const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00')
/**
* 规则的创建时间:**固定**在 2026-08-20。
*
* 必须早于所有参与断言的槽位,否则「槽位晚于创建时间」这道闸会把一切挡住;
* 也绝不能用 `Date.now()` —— 那样断言会随墙上时钟漂移。
*/
const CREATED_AT = Date.parse('2026-08-20T00:00:00+08:00')
const AFTER_SLOT = new Date(2026, 7, 27, 18, 31)
/** 必须早于所有参与断言的槽位,且固定,不能用 `Date.now()`。 */
const CREATED_AT = new Date(2026, 7, 20, 0, 0).getTime()
function makeRule(overrides: Partial<AutomationRule> = {}): AutomationRule {
return {
@@ -114,24 +114,18 @@ describe('定时日报 · 时间计算', () => {
expect(validateScheduleTime('09:05')).toBe(true)
expect(validateScheduleTime('24:00')).toBe(false)
expect(validateScheduleTime('9:5')).toBe(false)
const from = new Date('2026-08-27T10:00:00+08:00')
expect(calculateNextRunAt('18:30', from)).toBe(
new Date('2026-08-27T18:30:00+08:00').toISOString()
)
const from = new Date(2026, 7, 27, 10, 0)
expect(calculateNextRunAt('18:30', from)).toBe(localISO(2026, 8, 27, 18, 30))
// 已经过了今天的点 ⇒ 顺延到明天。
expect(calculateNextRunAt('09:00', from)).toBe(
new Date('2026-08-28T09:00:00+08:00').toISOString()
)
expect(calculateNextRunAt('09:00', from)).toBe(localISO(2026, 8, 28, 9, 0))
})
it('resolveDueScheduledSlot 给出"已经到点、最近的那个"槽位', () => {
// 18:31 时,18:30 已经到点 ⇒ 槽位是今天 18:30。
expect(resolveDueScheduledSlot(SCHEDULE_TIME, AFTER_SLOT)).toBe(
new Date('2026-08-27T18:30:00+08:00').toISOString()
)
expect(resolveDueScheduledSlot(SCHEDULE_TIME, AFTER_SLOT)).toBe(localISO(2026, 8, 27, 18, 30))
// 18:29 时,今天的点还没到 ⇒ 最近一个槽位是昨天。
expect(resolveDueScheduledSlot(SCHEDULE_TIME, new Date('2026-08-27T18:29:00+08:00'))).toBe(
new Date('2026-08-26T18:30:00+08:00').toISOString()
expect(resolveDueScheduledSlot(SCHEDULE_TIME, new Date(2026, 7, 27, 18, 29))).toBe(
localISO(2026, 8, 26, 18, 30)
)
})
})
@@ -148,7 +142,7 @@ describe('定时日报 · 调度器', () => {
expect(ruleId).toBe('rule-1')
expect(options).toEqual({
trigger: 'schedule',
scheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString()
scheduledSlot: localISO(2026, 8, 27, 18, 30)
})
})
@@ -159,7 +153,7 @@ describe('定时日报 · 调度器', () => {
schedule: { time: SCHEDULE_TIME },
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
}),
lastScheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString()
lastScheduledSlot: localISO(2026, 8, 27, 18, 30)
}
})
const { service, executeRule } = await makeHarness({}, [rule])
@@ -209,12 +203,12 @@ describe('定时日报 · 调度器', () => {
schedule: { time: SCHEDULE_TIME },
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
}),
lastScheduledSlot: new Date('2026-08-26T18:30:00+08:00').toISOString()
lastScheduledSlot: localISO(2026, 8, 26, 18, 30)
}
})
const { service, executeRule } = await makeHarness({}, [rule])
await service.tick(new Date('2026-08-27T18:29:00+08:00'))
await service.tick(new Date(2026, 7, 27, 18, 29))
await service.settle()
expect(executeRule).not.toHaveBeenCalled()
@@ -224,10 +218,10 @@ describe('定时日报 · 调度器', () => {
// 今天 12:00 建的规则、执行时间 18:30。今天 18:30 还没到,
// 最近的槽位是**昨天 18:30** —— 它早于 createdAt,不属于这条规则。
// 少了这道闸,新建规则会在下一次 15 秒 tick 里立刻发一份昨天的报告。
const rule = makeRule({ createdAt: Date.parse('2026-08-27T12:00:00+08:00') })
const rule = makeRule({ createdAt: new Date(2026, 7, 27, 12, 0).getTime() })
const { service, executeRule } = await makeHarness({}, [rule])
await service.tick(new Date('2026-08-27T18:29:00+08:00'))
await service.tick(new Date(2026, 7, 27, 18, 29))
await service.settle()
expect(executeRule).not.toHaveBeenCalled()
@@ -242,18 +236,18 @@ describe('定时日报 · 调度器', () => {
schedule: { time: SCHEDULE_TIME },
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
}),
lastScheduledSlot: new Date('2026-08-25T18:30:00+08:00').toISOString()
lastScheduledSlot: localISO(2026, 8, 25, 18, 30)
}
})
const { service, executeRule } = await makeHarness({}, [rule])
await service.tick(new Date('2026-08-27T18:29:00+08:00'))
await service.tick(new Date(2026, 7, 27, 18, 29))
await service.settle()
expect(executeRule).toHaveBeenCalledTimes(1)
expect(executeRule.mock.calls[0][1]).toEqual({
trigger: 'schedule',
scheduledSlot: new Date('2026-08-26T18:30:00+08:00').toISOString()
scheduledSlot: localISO(2026, 8, 26, 18, 30)
})
})
@@ -264,7 +258,7 @@ describe('定时日报 · 调度器', () => {
schedule: { time: SCHEDULE_TIME },
report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }
}),
lastScheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString()
lastScheduledSlot: localISO(2026, 8, 27, 18, 30)
}
})
const { service, executeRule } = await makeHarness({}, [rule])
@@ -272,7 +266,7 @@ describe('定时日报 · 调度器', () => {
// 18:31 时最近槽位正好等于游标 ⇒ 不触发;再把时间回拨到 18:29,
// 槽位退化成 08-26 18:30,仍然**比游标旧** ⇒ 同样不触发。
await service.tick(AFTER_SLOT)
await service.tick(new Date('2026-08-27T18:29:00+08:00'))
await service.tick(new Date(2026, 7, 27, 18, 29))
await service.settle()
expect(executeRule).not.toHaveBeenCalled()
+5 -2
View File
@@ -53,7 +53,7 @@ describe('sticker HTTP failures', () => {
})
describe('personal WeChat runtime security invariants', () => {
it('waits for the macOS native runtime to detach during application shutdown', () => {
it('keeps the macOS native runtime alive across application shutdown', () => {
const mainSource = readFileSync(resolve('src/main/index.ts'), 'utf8')
const shutdownStart = mainSource.indexOf("app.on('before-quit'")
const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart)
@@ -62,7 +62,10 @@ describe('personal WeChat runtime security invariants', () => {
expect(shutdownStart).toBeGreaterThanOrEqual(0)
expect(shutdownEnd).toBeGreaterThan(shutdownStart)
expect(shutdownSource).toContain('await Promise.all([')
expect(shutdownSource).toContain('macWechatRuntimeManager.shutdown()')
// host 要活过 TraceMemo(保留发送能力进程,保住已绑定的 frida 会话),
// 所以退出流程里**不得**关掉它 —— 否则每次重启都要重新绑定微信。
// 它有自己的生命周期:微信消失后自判 stale 并自退,也可从设置卡片手动重载。
expect(shutdownSource).not.toContain('macWechatRuntimeManager.shutdown()')
})
it('does not install Python packages while preparing the native runtime', () => {
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest'
import { createTrayImage, type TrayNativeImage } from '../../src/main/tray-icon'
function makeImage(empty = false): TrayNativeImage {
const image = {
addRepresentation: vi.fn(),
isEmpty: vi.fn(() => empty),
resize: vi.fn(),
setTemplateImage: vi.fn()
}
image.resize.mockReturnValue(image)
return image as unknown as TrayNativeImage
}
describe('tray icon creation', () => {
it('uses a monochrome 1x/2x template image on macOS', () => {
const image = makeImage()
const readImageFile = vi.fn((path: string) => Buffer.from(path))
const nativeImage = {
createEmpty: vi.fn(() => image),
createFromPath: vi.fn(() => makeImage())
}
const result = createTrayImage(
'darwin',
'/app/icon.png',
{ oneX: '/resources/trayTemplate.png', twoX: '/resources/trayTemplate@2x.png' },
nativeImage,
readImageFile
)
expect(result).toBe(image)
expect(readImageFile.mock.calls.map(([path]) => path)).toEqual([
'/resources/trayTemplate.png',
'/resources/trayTemplate@2x.png'
])
expect(image.addRepresentation).toHaveBeenNthCalledWith(1, {
scaleFactor: 1,
dataURL: `data:image/png;base64,${Buffer.from('/resources/trayTemplate.png').toString('base64')}`
})
expect(image.addRepresentation).toHaveBeenNthCalledWith(2, {
scaleFactor: 2,
dataURL: `data:image/png;base64,${Buffer.from('/resources/trayTemplate@2x.png').toString('base64')}`
})
expect(image.setTemplateImage).toHaveBeenCalledWith(true)
expect(nativeImage.createFromPath).not.toHaveBeenCalled()
})
it('preserves the app icon resize policy on Windows', () => {
const appImage = makeImage()
const nativeImage = {
createEmpty: vi.fn(() => makeImage()),
createFromPath: vi.fn(() => appImage)
}
const result = createTrayImage(
'win32',
'/app/icon.png',
{ oneX: '/resources/trayTemplate.png', twoX: '/resources/trayTemplate@2x.png' },
nativeImage,
vi.fn(() => Buffer.alloc(0))
)
expect(nativeImage.createFromPath).toHaveBeenCalledWith('/app/icon.png')
expect(appImage.resize).toHaveBeenCalledWith({ width: 24, height: 24, quality: 'best' })
expect(appImage.setTemplateImage).not.toHaveBeenCalled()
expect(result).toBe(appImage)
})
})
@@ -58,7 +58,10 @@ describe('wechat-ilink account store', () => {
expect(path).toBe(
join(home, '.tracememo', 'wechat-connector', 'accounts', 'bot_abc-im-bot.json')
)
// Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。
if (process.platform !== 'win32') {
expect(statSync(path).mode & 0o777).toBe(0o600)
}
expect(loadAllCredentials(() => home)).toEqual([credentials])
})
@@ -150,7 +153,10 @@ describe('wechat-ilink account store', () => {
accountsDirectory(() => home),
`${accountId}.context.json`
)
// Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。
if (process.platform !== 'win32') {
expect(statSync(path).mode & 0o777).toBe(0o600)
}
const stored = JSON.parse(readFileSync(path, 'utf8')) as {
tokens: Record<string, { context_token: string }>
+6
View File
@@ -45,7 +45,10 @@ describe('WechatInboundInbox', () => {
expect(accepted.map((entry) => entry.messageId)).toEqual(['1', '2'])
expect(accepted[0].contextToken).toBe('ctx-1')
// Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。
if (process.platform !== 'win32') {
expect(statSync(filePath).mode & 0o777).toBe(0o600)
}
expect(inbox.size()).toBe(2)
})
@@ -111,6 +114,9 @@ describe('WechatInboundInbox', () => {
const raw = readFileSync(filePath, 'utf8')
expect(raw).toContain('机密内容')
// Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。
if (process.platform !== 'win32') {
expect(statSync(filePath).mode & 0o777).toBe(0o600)
}
})
})