feat: 新增实验性微信卡片分享与自动部署能力

补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权
增加自动部署 Skill 和配置引导文档
优化报告工具栏、微信卡片弹窗及窄屏响应式布局
补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
This commit is contained in:
Wxw-Gu
2026-08-13 10:48:07 +08:00
parent d439b4b749
commit 4436d7c8ce
45 changed files with 3832 additions and 69 deletions
+8
View File
@@ -26,3 +26,11 @@ VITE_IMAGE_AES_KEY=
# Electron E2E test window close delay in milliseconds. # Electron E2E test window close delay in milliseconds.
# Local default: 2000 (2 seconds). Set to 0 for immediate close. # Local default: 2000 (2 seconds). Set to 0 for immediate close.
WXE_E2E_CLOSE_DELAY_MS=2000 WXE_E2E_CLOSE_DELAY_MS=2000
# Experimental self-hosted WeChat share-card service
# Copy these placeholders to .env. Never commit real AppSecret or UPLOAD_TOKEN values.
WECHAT_SHARE_DOMAIN=share.example.com
WECHAT_SHARE_APP_ID=
WECHAT_SHARE_APP_SECRET=
# Leave empty to let docs/skill/setup-wechat-share-card/scripts/setup.sh generate one.
WECHAT_SHARE_UPLOAD_TOKEN=
+2
View File
@@ -12,6 +12,8 @@ test-results/
resources/connectors/wechat/ resources/connectors/wechat/
.omc .omc
.codex/ .codex/
services/share-card-worker/.wrangler/
services/share-card-worker/wrangler.local.jsonc
docs/design/ docs/design/
docs/ui-redesign-plan.md docs/ui-redesign-plan.md
docs/ui-redesign-spec.md docs/ui-redesign-spec.md
+2
View File
@@ -12,6 +12,8 @@ TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块
- [建立本地知识库](./user-guide/knowledge.md) - [建立本地知识库](./user-guide/knowledge.md)
- [生成群聊日报和总结](./user-guide/report.md) - [生成群聊日报和总结](./user-guide/report.md)
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
- [语音转文字](./user-guide/voice.md) - [语音转文字](./user-guide/voice.md)
- [导出聊天档案](./user-guide/export.md) - [导出聊天档案](./user-guide/export.md)
- [防撤回](./user-guide/recall-protection.md) - [防撤回](./user-guide/recall-protection.md)
@@ -0,0 +1,447 @@
# 实验性功能:自托管微信分享卡片
![微信卡片分享效果示例](../../public/微信卡片分享.png)
> **实验性功能**
> 该能力需要用户自行准备 Cloudflare、域名和微信公众平台测试号,目前不属于开箱即用的稳定功能。Cloudflare、微信 JS-SDK、测试号权限或微信客户端行为变化,都可能导致分享卡片失效。
## 新手推荐:直接交给 Agent
如果你不熟悉 Cloudflare、Wrangler 或命令行,不需要手动照着整篇文档操作。把下面这个 Skill 文件夹交给 Codex、Claude Code 或其他能够操作项目终端的编程 Agent:
```text
docs/skill/setup-wechat-share-card/
```
然后对 Agent 说:
```text
请使用 setup-wechat-share-card Skill,帮我部署 TraceMemo 的实验性微信分享卡片服务。尽量自动完成,只在缺少必要信息时一次性问我。
```
Agent 会自动:
- 检查 Node.js、pnpm 和 Wrangler
- 必要时临时下载 Wrangler
- 打开 Cloudflare 登录并执行 `whoami`
- 自动生成 `UPLOAD_TOKEN`
- 创建或复用私有 R2 Bucket
- 写入 Worker Secret
- 根据你的域名生成本机 Worker 配置;
- 部署 Worker并执行健康检查和微信签名检查;
- 把上传密钥复制到剪贴板,供你粘贴到 TraceMemo。
Agent 无法替你创建微信测试号或决定使用哪个域名,因此通常只需要你提供:
1. 你准备使用的分享域名,例如 `share.example.com`
2. 微信测试号页面中的 AppID
3. 微信测试号页面中的 AppSecret;
4. 浏览器弹出 Cloudflare OAuth 页面时完成一次登录授权。
真实配置保存在被 Git 忽略的本机 `.env` 中,不会写入 `.env.example`。不要把 `.env` 发给别人或提交到仓库。
TraceMemo 可以把已经生成的群聊日报长图发布为一个临时网页,并在微信中分享成带有标题、描述和缩略图的卡片。
TraceMemo **不提供公共卡片服务器**。使用该功能前,需要按照本文部署一套属于你自己的卡片服务。日报图片将上传到你自己的 Cloudflare R2,而不是上传到 TraceMemo 作者的服务器。
## 这个功能解决什么问题
直接把日报 PNG 发到微信,只会显示为一张普通图片。微信卡片还需要:
- 一个域名 (未能备案的话 在微信里点击多次 可能会被微信内置窗口提示需要备案);
- 卡片标题和描述;
- 一张微信可以读取的缩略图;
- 微信 JS-SDK 签名;
- 一个临时保存日报图片的位置。
本项目提供的 Cloudflare Worker 负责这些工作。桌面端上传日报后,会得到一个分享链接和二维码。用微信扫码打开链接,再点击右上角菜单分享,即可生成微信卡片。
## 数据会经过哪里
```mermaid
flowchart LR
A[TraceMemo 本机日报 PNG] -->|带 UPLOAD_TOKEN 上传| B[你的 Cloudflare Worker]
B --> C[你的私有 R2 Bucket]
B -->|AppID + AppSecret| D[微信公众平台接口]
D -->|access_token 与 jsapi_ticket| B
B --> E[临时分享网页]
E --> F[微信 JS-SDK]
F --> G[微信好友或群聊卡片]
```
与 TraceMemo 的本地浏览能力不同,启用分享卡片后,当前日报长图、缩略图、卡片标题和描述会离开本机,上传到你控制的 Cloudflare 账号。
## 你需要准备什么
| 项目 | 用途 | 从哪里获得 |
| ------------------------ | -------------------------------------------- | ---------------------------------------- |
| Cloudflare 账号 | 运行 Worker 和保存 R2 图片 | 自行注册 Cloudflare |
| 托管在 Cloudflare 的域名 | 提供 HTTPS 分享地址 | 使用自己的域名,例如 `share.example.com` |
| R2 Bucket | 临时保存日报和缩略图 | 使用 Wrangler 创建 |
| `UPLOAD_TOKEN` | 阻止陌生人调用你的上传接口 | **由你自己随机生成** |
| 微信测试号 AppID | 标识调用 JS-SDK 的微信应用 | 微信公众平台接口测试号页面 |
| 微信测试号 AppSecret | Worker 获取微信接口凭据 | 微信公众平台接口测试号页面 |
| JS 接口安全域名 | 告诉微信哪些网页可以使用该 AppID 调用 JS-SDK | 在微信测试号页面填写你的分享域名 |
微信公众平台接口测试号入口:
<https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index>
微信 JS-SDK 官方文档:
<https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html>
## 理解三个重要配置
### `UPLOAD_TOKEN` 从哪里来
`UPLOAD_TOKEN` **不是从 Cloudflare 或微信后台领取的**,它是卡片服务部署者自己生成的一段随机密码。
它用于保护 Worker 的上传接口:TraceMemo 上传日报时,会发送:
```http
Authorization: Bearer <UPLOAD_TOKEN>
```
Worker 只有在密钥完全一致时才接受上传。没有它,任何知道接口地址的人都可能向你的 R2 上传文件并消耗资源。
在 macOS 或 Linux 中生成一枚 64 位十六进制随机密钥:
```bash
openssl rand -hex 32
```
示例输出只用于说明格式,不要直接使用:
```text
8a4d...一共 64 个十六进制字符...72ef
```
生成后,同一个值需要配置到两个地方:
1. Cloudflare Worker Secret `UPLOAD_TOKEN`
2. TraceMemo“生成微信卡片”弹窗中的“上传密钥”。
如果两边不一致,卡片服务会返回 HTTP 401 或“未授权”。
TraceMemo 会使用 Electron `safeStorage` 将服务地址和上传密钥加密保存在本机。不要把密钥提交到 Git,也不要写入 `wrangler.jsonc`
### AppID 和 AppSecret 从哪里来
打开[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index),使用微信扫码登录。
页面上方会显示:
- `appID`
- `appsecret`
将它们分别保存为 Worker Secret
```text
WECHAT_APP_ID
WECHAT_APP_SECRET
```
它们的作用不同:
- AppID 用于标识这个微信测试应用;
- AppSecret 是高敏感凭据,Worker 用它向微信服务器获取 `access_token`
- Worker 再使用 `access_token` 获取 `jsapi_ticket`
- 最后使用 `jsapi_ticket`、当前网页 URL、时间戳和随机串生成 JS-SDK 签名。
AppSecret 只能保存在 Worker Secret 中。不要把它填写到 TraceMemo 的“上传密钥”输入框,不要发送给前端,也不要提交到仓库。怀疑泄露时,应立即在微信后台重置并更新 Worker Secret。
### JS 接口安全域名是干什么的
JS 接口安全域名是微信对网页来源的白名单。
假设你的分享服务地址是:
```text
https://share.example.com
```
那么测试号页面中的“JS 接口安全域名”应填写:
```text
share.example.com
```
填写时:
- 不带 `https://`
- 不带 `/s/xxx` 等路径;
- 不要填写 Cloudflare Worker 名称;
- 必须与用户实际打开分享页时的域名一致。
它不是用来解析 DNS 的。域名仍然需要先在 Cloudflare 中正确绑定到 Worker。安全域名的作用是告诉微信:允许这个域名下的网页使用当前 AppID 请求 JS-SDK 能力。
如果没有配置、填错域名,或签名 URL 与实际页面 URL 不一致,通常会出现 `invalid signature``config:fail` 或分享信息没有生效。
微信可能要求下载一个 TXT 验证文件,并确保它可以通过下面的地址访问:
```text
https://share.example.com/微信提供的文件名.txt
```
项目 Worker 已包含根路径验证文件的实现方式。你需要把自己的文件名和内容加入 `services/share-card-worker/src/index.js` 中的 `WECHAT_DOMAIN_VERIFICATION`,然后重新部署。
## 自托管部署步骤
以下命令均在项目根目录执行。
### 1. 登录 Cloudflare
项目建议使用本地 Wrangler
```bash
pnpm exec wrangler login
pnpm exec wrangler whoami
```
如果本地版本的 OAuth 登录出现 `invalid_scope` 等问题,可临时使用更新版本:
```bash
pnpm dlx wrangler@latest login
pnpm dlx wrangler@latest whoami
```
登录注意事项:
- 让 Wrangler 自动打开浏览器最稳妥;
- 不要复用以前生成的 OAuth 链接;
- 不要修改链接中的 `state``code_challenge` 或回调地址;
- 不建议使用无痕窗口或跨浏览器复制链接;
- 默认回调使用 `localhost:8976`,端口被占用时先结束旧的 Wrangler 登录进程;
- 浏览器提示授权成功后,仍应通过 `whoami` 核对账号。
### 2. 修改 Worker 配置
打开:
```text
services/share-card-worker/wrangler.jsonc
```
至少修改下面两个位置:
```jsonc
{
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
```
`routes[].pattern` 是 Worker 自定义域名,`PUBLIC_ORIGIN` 是生成分享链接和校验签名来源时使用的完整 HTTPS 地址,两者必须一致。
不要直接照抄仓库维护者的域名。请替换为你自己 Cloudflare 账号中的域名或子域名。
### 3. 创建私有 R2 Bucket
默认配置使用 Bucket 名称:
```text
wechatexplorer-share-reports
```
创建:
```bash
pnpm exec wrangler r2 bucket create wechatexplorer-share-reports \
--config services/share-card-worker/wrangler.jsonc
```
Worker 中的绑定名称是 `REPORTS`。R2 会保存:
```text
cards/<card-id>/card.json
cards/<card-id>/report.png
cards/<card-id>/thumbnail.jpg
```
- `card.json`:标题、描述、创建时间和过期时间;
- `report.png`:完整日报长图;
- `thumbnail.jpg`:微信卡片缩略图。
请保持 R2 Bucket 私有,不要启用公开 `r2.dev` 开发 URL。图片应统一通过 Worker 的随机卡片 URL 读取。
### 4. 生成并配置 `UPLOAD_TOKEN`
```bash
openssl rand -hex 32
```
复制生成结果,然后执行:
```bash
pnpm exec wrangler secret put UPLOAD_TOKEN \
--config services/share-card-worker/wrangler.jsonc
```
Wrangler 提示输入时粘贴密钥。终端不会正常显示 Secret 内容。
### 5. 配置微信 AppID 和 AppSecret
从[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)复制 AppID
```bash
pnpm exec wrangler secret put WECHAT_APP_ID \
--config services/share-card-worker/wrangler.jsonc
```
再复制 AppSecret
```bash
pnpm exec wrangler secret put WECHAT_APP_SECRET \
--config services/share-card-worker/wrangler.jsonc
```
Secret 不会出现在 `wrangler.jsonc` 中。如果你更换 Cloudflare 账号或重新创建 Worker,需要重新配置全部三个 Secret。
### 6. 配置微信测试号
在测试号页面完成:
1. 使用测试微信关注该测试号;
2.`share.example.com` 填入“JS 接口安全域名”;
3. 按页面提示完成 TXT 文件域名验证;
4. 确认 AppID/AppSecret 与刚才写入 Worker 的值来自同一个测试号。
测试号只适合开发和验证。正式公众号的接口权限、认证要求和后台菜单可能不同,请以微信公众平台实际规则为准。
### 7. 部署 Worker
```bash
pnpm exec wrangler deploy \
--config services/share-card-worker/wrangler.jsonc
```
Cloudflare Custom Domain 要求域名已经位于同一 Cloudflare 账号中。如果该子域名已经存在 A、AAAA 或 CNAME 记录,绑定可能失败。删除冲突记录,或者换一个未使用的子域名,例如 `share2.example.com`
更新 Secret 后,如果线上仍提示旧配置,可再执行一次完整部署。
## 在 TraceMemo 中配置
生成一份日报后,点击“生成微信卡片(实验性)”。首次使用需要填写:
```text
服务地址:https://share.example.com
上传密钥:你自己通过 openssl rand -hex 32 生成的 UPLOAD_TOKEN
```
这里的“上传密钥”绝对不是微信 AppSecret。
配置保存后,TraceMemo 会上传当前日报和缩略图,返回二维码。使用已经关注测试号的微信扫码,打开页面后再通过右上角菜单分享。
## 验证部署
### 健康检查
```bash
curl -fsS https://share.example.com/health
```
正常结果类似:
```json
{ "ok": true, "service": "wechatexplorer-share-card", "storage": "ready" }
```
### JS-SDK 签名检查
```bash
curl -fsS \
'https://share.example.com/api/wx-signature?url=https%3A%2F%2Fshare.example.com%2Fhealth'
```
正常结果应包含:
```text
appId
timestamp
nonceStr
signature
```
响应中不应包含 AppSecret、`access_token``jsapi_ticket`
## 常见问题
### HTTP 401 / 未授权
TraceMemo 中保存的上传密钥与 Worker 的 `UPLOAD_TOKEN` 不一致。重新生成或重新配置时,必须同步更新两边。
### “微信 JS-SDK 尚未配置”
Worker 缺少 `WECHAT_APP_ID``WECHAT_APP_SECRET`。执行两个 `secret put`,再重新部署。
### `invalid signature` 或分享信息不生效
依次检查:
- `PUBLIC_ORIGIN` 是否与浏览器实际访问的 origin 完全一致;
- JS 接口安全域名是否只填写了域名;
- AppID/AppSecret 是否属于同一个测试号;
- AppSecret 是否已被重置但 Worker 仍保存旧值;
- 分享页面是否经过了改变 URL 的代理或重定向;
- 测试微信是否已关注测试号。
### 自定义域名绑定失败
检查同名 A、AAAA、CNAME 记录是否已经存在,域名是否位于当前 Wrangler 登录的 Cloudflare 账号中。
### R2 未配置
确认 Bucket 存在,并且 `wrangler.jsonc` 中的绑定名称为 `REPORTS``bucket_name` 与实际 Bucket 一致。
### 卡片过期或图片消失
默认有效期为 7 天。Worker 的定时任务会删除过期卡片的元数据、日报和缩略图,这是设计行为。
## 安全和隐私注意事项
- 日报可能包含敏感群聊内容。只分享你有权分享的内容。
- 获得分享 URL 的人,在过期前可能查看对应日报;当前实现不是按访问者身份授权。
- `UPLOAD_TOKEN` 是整个 Worker 的服务级密钥,不是每个用户独立的账号凭据。
- 不要把 `UPLOAD_TOKEN`、AppSecret 或 Wrangler 凭据提交到 Git。
- R2 保持私有,不要把 Bucket 直接公开。
- 建议定期轮换 `UPLOAD_TOKEN`,怀疑泄露时立即轮换。
- 微信 AppSecret 泄露时,应在微信后台重置,并立即更新 Worker Secret。
- 自托管者自行承担 Cloudflare 用量、域名、数据合规和微信平台规则相关责任。
## 当前实验性限制
- 需要用户自己部署,普通用户无法直接开箱使用;
- 使用一个共享的 `UPLOAD_TOKEN`,没有多用户账号系统;
- 分享链接在有效期内属于“知道链接即可访问”;
- 依赖微信 JS-SDK 和测试号能力,微信侧规则变化可能造成失效;
- 当前仅上传 PNG 日报和 JPEG 缩略图;
- 没有管理后台用于列出、提前删除或审计所有卡片;
- 过期清理由定时任务完成,不保证到期瞬间立即删除。
## 代码入口
- Worker`services/share-card-worker/src/index.js`
- Worker 配置:`services/share-card-worker/wrangler.jsonc`
- Worker 测试:`services/share-card-worker/test/index.test.js`
- 桌面端上传:`src/main/wechat-share-card-service.ts`
- 本地加密配置:`src/main/wechat-share-config-store.ts`
- 分享弹窗:`src/renderer/src/components/reports/WechatShareCardDialog.tsx`
- 共享类型:`src/shared/wechat-share-card.ts`
## 参考资料
- [微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)
- [微信 JS-SDK 官方文档](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html)
- [Cloudflare Wrangler 命令文档](https://developers.cloudflare.com/workers/wrangler/commands/)
- [Cloudflare R2 Wrangler 命令](https://developers.cloudflare.com/workers/wrangler/commands/r2/)
- [在 Worker 中绑定和使用 R2](https://developers.cloudflare.com/r2/api/workers/workers-api-usage/)
- [Cloudflare Worker Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
@@ -0,0 +1,92 @@
---
name: setup-wechat-share-card
description: 自动配置和部署 TraceMemo 实验性微信分享卡片服务。用户要求启用、部署、修复或迁移微信分享卡片,配置 Cloudflare Worker/R2/Wrangler,设置 UPLOAD_TOKEN、微信测试号 AppID/AppSecret、JS 接口安全域名,或希望由 Codex、Claude Code 等 Agent 代替手工阅读部署文档时使用。
---
# 部署微信分享卡片
尽量自行发现项目状态并完成部署。只在自动检查后仍缺少必要信息时,一次性询问用户;不要逐项反复确认。
## 安全边界
- 不把真实 AppSecret、`UPLOAD_TOKEN`、Cloudflare Token 或微信验证内容写入 Git。
- `.env.example` 只保存占位符;真实值写入项目根目录 `.env`,该文件必须被 Git 忽略。
- 不在最终回答中回显 Secret。日志中只报告“已配置/缺失”。
- `UPLOAD_TOKEN` 默认自动生成,不要求用户提供。
- AppID/AppSecret 必须来自用户自己的微信测试号或公众号。无法自动获取时才询问。
- 部署、创建 R2 和写 Secret 属于用户明确请求本 Skill 后的正常动作;不要提交、推送或创建 PR,除非用户另外明确要求。
## 自动工作流
1. 定位 TraceMemo 仓库根目录。确认存在 `services/share-card-worker/wrangler.jsonc`
2. 运行:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh doctor
```
3. 检查根目录 `.env`。脚本会自动复用已有配置并生成缺失的 `WECHAT_SHARE_UPLOAD_TOKEN`。
4. 如果以下值缺失,只向用户发起一次集中询问:
- 分享域名,例如 `share.example.com`
- 微信测试号 AppID
- 微信测试号 AppSecret。
5. 用户不知道从哪里获取时,告诉他打开:
```text
https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index
```
使用微信扫码登录后,复制页面上的 `appID` 和 `appsecret`。提醒用户把分享域名填入“JS 接口安全域名”,不带 `https://` 和路径。
6. 将缺失值交给交互脚本,不要把 Secret 放进命令行参数:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh configure
```
该命令通过终端交互收集缺项,AppSecret 使用隐藏输入。
7. 执行完整部署:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh deploy
```
脚本会依次:
- 检查或临时下载 Wrangler
- 启动 Cloudflare OAuth 登录;
- 执行 `whoami`
- 生成本地 `wrangler.local.jsonc`
- 创建或复用 R2 Bucket
- 写入三个 Worker Secret
- 部署 Worker
- 检查 `/health` 和微信签名接口。
8. OAuth 页面出现时,让用户只完成浏览器登录/授权;不要改用 API Token,除非用户主动要求。
9. 部署后把服务地址告诉用户,并提醒他在 TraceMemo 卡片弹窗粘贴 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN`。优先把 Token 复制到剪贴板,不在聊天中展示:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh copy-token
```
10. 如果微信要求 TXT 验证文件,读取 [references/wechat-domain-verification.md](references/wechat-domain-verification.md),取得用户提供的文件后再修改 Worker。
## 决策规则
- Wrangler 未安装:优先使用项目依赖;否则通过 `pnpm dlx wrangler@latest` 临时下载,不强制全局安装。
- `whoami` 已登录正确账号:不要重复登录。
- R2 已存在:继续,不把“已存在”视为失败。
- 自定义域名有 A/AAAA/CNAME 冲突:报告准确域名并要求用户选择删除冲突记录或换子域名;不要擅自删除 DNS。
- HTTP 401:重新同步 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN` 到 Worker,再让用户更新 TraceMemo。
- “微信 JS-SDK 尚未配置”:重新写入 AppID/AppSecret 并部署。
- 微信返回 AppID/AppSecret 错误:让用户检查是否来自同一个测试号、AppSecret 是否已重置。
- 缺少 JS 接口安全域名或测试号关注:这是微信后台操作,明确告诉用户要填写什么,不要假装已完成。
## 验证结果
完成前必须确认:
- `wrangler whoami` 成功;
- Worker 部署成功;
- `/health` 返回 `storage: ready`
- `/api/wx-signature` 返回 `appId`、`timestamp`、`nonceStr`、`signature`
- Git 扫描未发现 `.env`、真实 AppSecret、上传密钥或用户域名被暂存。
详细产品和架构说明见:`docs/deployment/experimental-wechat-share-card.md`。
@@ -0,0 +1,4 @@
interface:
display_name: "部署微信分享卡片"
short_description: "让 Agent 自动完成微信分享卡片服务的配置与部署"
default_prompt: "Use $setup-wechat-share-card to configure and deploy my self-hosted WeChat share-card service with minimal questions."
@@ -0,0 +1,15 @@
# 微信域名验证文件
当微信测试号页面要求下载 TXT 文件时:
1. 向用户索取 TXT 文件本身,或文件名与完整内容。
2. 不把真实验证内容写进公开仓库历史。
3. 优先在本地私有配置中注入;若当前 Worker 只能通过源码 Map 返回,则先提醒用户该值会进入工作区,确认仓库发布前必须移除或改造成 Secret/变量。
4. 验证目标必须是:
```text
https://<分享域名>/<微信提供的文件名>.txt
```
5. 返回内容必须是纯文本且与微信提供内容完全一致,不加空格、HTML 或额外换行。
6. 完成验证后再配置 JS 接口安全域名。安全域名只填主机名,不带协议或路径。
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
ENV_FILE="$REPO_ROOT/.env"
EXAMPLE_FILE="$REPO_ROOT/.env.example"
WORKER_DIR="$REPO_ROOT/services/share-card-worker"
BASE_CONFIG="$WORKER_DIR/wrangler.jsonc"
LOCAL_CONFIG="$WORKER_DIR/wrangler.local.jsonc"
BUCKET_NAME="wechatexplorer-share-reports"
cd "$REPO_ROOT"
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
info() {
printf '[share-card] %s\n' "$*"
}
require_project() {
[[ -f "$BASE_CONFIG" ]] || fail "请在 TraceMemo 仓库根目录运行此脚本"
[[ -f "$EXAMPLE_FILE" ]] || fail "缺少 .env.example"
}
ensure_env_file() {
if [[ ! -f "$ENV_FILE" ]]; then
cp "$EXAMPLE_FILE" "$ENV_FILE"
chmod 600 "$ENV_FILE"
info "已从 .env.example 创建本机 .env"
fi
}
read_env_value() {
local key="$1"
local line
line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
printf '%s' "${line#*=}"
}
write_env_value() {
local key="$1"
local value="$2"
local escaped
escaped="$(printf '%s' "$value" | sed 's/[\\&|]/\\&/g')"
if grep -q -E "^${key}=" "$ENV_FILE"; then
sed -i.bak -E "s|^${key}=.*$|${key}=${escaped}|" "$ENV_FILE"
command rm "$ENV_FILE.bak"
else
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
}
normalize_domain() {
local value="$1"
value="${value#http://}"
value="${value#https://}"
value="${value%%/*}"
printf '%s' "$value"
}
ensure_upload_token() {
local token
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
if [[ ${#token} -lt 32 ]]; then
token="$(openssl rand -hex 32)"
write_env_value WECHAT_SHARE_UPLOAD_TOKEN "$token"
info "已生成新的 UPLOAD_TOKEN 并安全写入 .env"
fi
}
wrangler() {
if [[ -x "$REPO_ROOT/node_modules/.bin/wrangler" ]]; then
"$REPO_ROOT/node_modules/.bin/wrangler" "$@"
elif command -v pnpm >/dev/null 2>&1; then
pnpm dlx wrangler@latest "$@"
elif command -v npx >/dev/null 2>&1; then
npx --yes wrangler@latest "$@"
else
fail "需要 Node.js 以及 pnpm 或 npm 才能运行 Wrangler"
fi
}
ensure_wrangler_login() {
if wrangler whoami >/dev/null 2>&1; then
wrangler whoami
return
fi
info "即将打开 Cloudflare OAuth 登录,请在浏览器中完成授权"
wrangler login
wrangler whoami
}
validate_required_config() {
local domain app_id app_secret token
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
[[ -n "$domain" && "$domain" != "share.example.com" ]] || fail "缺少真实 WECHAT_SHARE_DOMAIN"
[[ -n "$app_id" ]] || fail "缺少 WECHAT_SHARE_APP_ID"
[[ -n "$app_secret" ]] || fail "缺少 WECHAT_SHARE_APP_SECRET"
[[ ${#token} -ge 32 ]] || fail "WECHAT_SHARE_UPLOAD_TOKEN 长度不足"
}
configure_interactively() {
ensure_env_file
local domain app_id app_secret
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
if [[ -z "$domain" || "$domain" == "share.example.com" ]]; then
read -r -p '分享域名(例如 share.example.com,不带 https://):' domain
domain="$(normalize_domain "$domain")"
[[ -n "$domain" ]] || fail "分享域名不能为空"
write_env_value WECHAT_SHARE_DOMAIN "$domain"
fi
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
if [[ -z "$app_id" ]]; then
read -r -p '微信测试号 AppID' app_id
[[ -n "$app_id" ]] || fail "AppID 不能为空"
write_env_value WECHAT_SHARE_APP_ID "$app_id"
fi
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
if [[ -z "$app_secret" ]]; then
read -r -s -p '微信测试号 AppSecret(输入不会显示):' app_secret
printf '\n'
[[ -n "$app_secret" ]] || fail "AppSecret 不能为空"
write_env_value WECHAT_SHARE_APP_SECRET "$app_secret"
fi
ensure_upload_token
info "本机配置已准备完成"
}
generate_local_config() {
local domain
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
cat > "$LOCAL_CONFIG" <<EOF
{
"\$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [{ "pattern": "$domain", "custom_domain": true }],
"r2_buckets": [{ "binding": "REPORTS", "bucket_name": "$BUCKET_NAME" }],
"triggers": { "crons": ["17 3 * * *"] },
"vars": {
"PUBLIC_ORIGIN": "https://$domain",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
EOF
info "已生成本机 Worker 配置 services/share-card-worker/wrangler.local.jsonc"
}
assert_secrets_not_tracked() {
git check-ignore -q .env || fail ".env 未被 Git 忽略,请停止部署并检查 .gitignore"
if git ls-files --error-unmatch .env >/dev/null 2>&1; then
fail ".env 已被 Git 跟踪,请先从索引移除"
fi
if git diff --cached --name-only | grep -Eq '(^|/)\.env$|wrangler\.local\.jsonc$'; then
fail "敏感本机配置已被暂存,请先取消暂存"
fi
}
create_bucket_if_needed() {
local output
set +e
output="$(wrangler r2 bucket create "$BUCKET_NAME" --config "$LOCAL_CONFIG" 2>&1)"
local status=$?
set -e
if [[ $status -eq 0 ]]; then
printf '%s\n' "$output"
elif printf '%s' "$output" | grep -Eqi 'already exists|already owned|10004'; then
info "R2 Bucket 已存在,继续部署"
else
printf '%s\n' "$output" >&2
fail "创建 R2 Bucket 失败"
fi
}
put_secrets() {
local upload_token app_id app_secret
upload_token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
printf '%s' "$upload_token" | wrangler secret put UPLOAD_TOKEN --config "$LOCAL_CONFIG"
printf '%s' "$app_id" | wrangler secret put WECHAT_APP_ID --config "$LOCAL_CONFIG"
printf '%s' "$app_secret" | wrangler secret put WECHAT_APP_SECRET --config "$LOCAL_CONFIG"
}
verify_service() {
local domain health signature
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
health="$(curl -fsS --retry 5 --retry-delay 2 "https://$domain/health")"
printf '%s' "$health" | grep -q '"storage":"ready"' || fail "健康检查未返回 storage: ready"
signature="$(curl -fsS --retry 3 --retry-delay 2 "https://$domain/api/wx-signature?url=https%3A%2F%2F${domain}%2Fhealth")"
printf '%s' "$signature" | grep -q '"signature"' || fail "微信 JS-SDK 签名检查失败:$signature"
info "服务验证成功:https://$domain"
}
doctor() {
require_project
ensure_env_file
ensure_upload_token
assert_secrets_not_tracked
info "Node: $(node --version 2>/dev/null || printf '未安装')"
info "pnpm: $(pnpm --version 2>/dev/null || printf '未安装')"
if wrangler --version >/dev/null 2>&1; then
info "Wrangler 可用:$(wrangler --version | tail -n 1)"
else
fail "Wrangler 无法运行"
fi
local domain app_id app_secret
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
[[ -n "$domain" && "$domain" != "share.example.com" ]] && info "分享域名:已配置" || info "分享域名:缺失"
[[ -n "$app_id" ]] && info "微信 AppID:已配置" || info "微信 AppID:缺失"
[[ -n "$app_secret" ]] && info "微信 AppSecret:已配置" || info "微信 AppSecret:缺失"
info "UPLOAD_TOKEN:已配置"
}
deploy() {
require_project
ensure_env_file
ensure_upload_token
validate_required_config
assert_secrets_not_tracked
ensure_wrangler_login
generate_local_config
create_bucket_if_needed
put_secrets
wrangler deploy --config "$LOCAL_CONFIG"
verify_service
}
copy_token() {
ensure_env_file
ensure_upload_token
local token
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
if command -v pbcopy >/dev/null 2>&1; then
printf '%s' "$token" | pbcopy
elif command -v wl-copy >/dev/null 2>&1; then
printf '%s' "$token" | wl-copy
elif command -v xclip >/dev/null 2>&1; then
printf '%s' "$token" | xclip -selection clipboard
else
fail "未找到剪贴板工具;请让用户自行从 .env 读取 WECHAT_SHARE_UPLOAD_TOKEN"
fi
info "UPLOAD_TOKEN 已复制到剪贴板"
}
case "${1:-doctor}" in
doctor) doctor ;;
configure) configure_interactively ;;
deploy) deploy ;;
copy-token) copy_token ;;
*) fail "用法:$0 {doctor|configure|deploy|copy-token}" ;;
esac
+4 -1
View File
@@ -78,6 +78,7 @@
"jsonrepair": "^3.15.0", "jsonrepair": "^3.15.0",
"koffi": "^3.1.0", "koffi": "^3.1.0",
"openai": "^6.10.0", "openai": "^6.10.0",
"qrcode": "^1.5.4",
"sherpa-onnx-node": "1.13.3", "sherpa-onnx-node": "1.13.3",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2" "wechat-emojis": "^1.0.2"
@@ -95,6 +96,7 @@
"@types/archiver": "^8.0.0", "@types/archiver": "^8.0.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1", "@types/node": "^22.19.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
@@ -113,7 +115,8 @@
"sass": "^1.102.0", "sass": "^1.102.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6", "vite": "^7.2.6",
"vitest": "^4.1.10" "vitest": "^4.1.10",
"wrangler": "^4.28.1"
}, },
"pnpm": { "pnpm": {
"supportedArchitectures": { "supportedArchitectures": {
+856 -14
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
# WechatExplorer share-card worker
Cloudflare Worker + private R2 service for temporary WeChat report cards.
This is an experimental, self-hosted feature. See the complete Chinese deployment guide:
- `../../docs/deployment/experimental-wechat-share-card.md`
Required encrypted secrets:
- `WECHAT_APP_ID`
- `WECHAT_APP_SECRET`
- `UPLOAD_TOKEN` (random 32+ character token also saved in WechatExplorer's secure settings)
Create the private bucket, set secrets, and deploy:
```bash
npx wrangler r2 bucket create wechatexplorer-share-reports
npx wrangler secret put WECHAT_APP_ID
npx wrangler secret put WECHAT_APP_SECRET
npx wrangler secret put UPLOAD_TOKEN
npx wrangler deploy
```
Keep the R2 public development URL disabled. All reads go through the Worker and expire with
the card.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "wechatexplorer-share-card-worker",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"check": "node --check src/index.js && node --test test/index.test.js"
},
"devDependencies": {
"wrangler": "^4.28.1"
}
}
+318
View File
@@ -0,0 +1,318 @@
const encoder = new TextEncoder()
const DAY_MS = 86_400_000
// Add the TXT filename and content supplied by the WeChat test-account page when
// domain verification is required. Never commit a real verification value.
const WECHAT_DOMAIN_VERIFICATION = new Map()
const json = (value, init = {}) =>
new Response(JSON.stringify(value), {
...init,
headers: { 'content-type': 'application/json; charset=utf-8', ...(init.headers || {}) }
})
const escapeHtml = (value) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const safeJson = (value) => JSON.stringify(value).replaceAll('<', '\\u003c')
const cardKey = (id) => `cards/${id}/card.json`
const imageKey = (id) => `cards/${id}/report.png`
const thumbnailKey = (id) => `cards/${id}/thumbnail.jpg`
const storageUnavailable = () =>
json(
{
error: '图片存储服务尚未启用,请在 Cloudflare 控制台启用 R2 后重新生成分享卡片'
},
{ status: 503 }
)
const readCard = async (env, id) => {
if (!/^[0-9a-f-]{36}$/i.test(id)) return null
const object = await env.REPORTS.get(cardKey(id))
if (!object) return null
const card = JSON.parse(await object.text())
if (Date.parse(card.expiresAt) <= Date.now()) {
await deleteCard(env, id)
return null
}
return card
}
const deleteCard = async (env, id) => {
await env.REPORTS.delete([cardKey(id), imageKey(id), thumbnailKey(id)])
}
const bearerAuthorized = (request, env) => {
const value = request.headers.get('authorization') || ''
return Boolean(env.UPLOAD_TOKEN) && value === `Bearer ${env.UPLOAD_TOKEN}`
}
const publicOrigin = (request, env) =>
String(env.PUBLIC_ORIGIN || new URL(request.url).origin).replace(/\/+$/, '')
const createCard = async (request, env) => {
if (!bearerAuthorized(request, env)) return json({ error: '未授权' }, { status: 401 })
const body = await request.json().catch(() => null)
if (!body) return json({ error: '请求体无效' }, { status: 400 })
const title = String(body.title || '')
.trim()
.slice(0, 64)
const description = String(body.description || '')
.trim()
.slice(0, 120)
if (!title || !body.imageBase64 || !body.thumbnailBase64) {
return json({ error: '缺少标题或图片' }, { status: 400 })
}
const image = Uint8Array.from(atob(body.imageBase64), (char) => char.charCodeAt(0))
const thumbnail = Uint8Array.from(atob(body.thumbnailBase64), (char) => char.charCodeAt(0))
if (image.byteLength > 25 * 1024 * 1024 || thumbnail.byteLength > 2 * 1024 * 1024) {
return json({ error: '图片超过大小限制' }, { status: 413 })
}
const id = crypto.randomUUID()
const days = Math.max(1, Math.min(30, Number(body.expiresInDays || env.DEFAULT_EXPIRY_DAYS || 7)))
const createdAt = new Date().toISOString()
const expiresAt = new Date(Date.now() + days * DAY_MS).toISOString()
const card = { id, title, description, createdAt, expiresAt }
await Promise.all([
env.REPORTS.put(cardKey(id), JSON.stringify(card), {
httpMetadata: { contentType: 'application/json; charset=utf-8' }
}),
env.REPORTS.put(imageKey(id), image, {
httpMetadata: { contentType: 'image/png', cacheControl: 'private, max-age=300' }
}),
env.REPORTS.put(thumbnailKey(id), thumbnail, {
httpMetadata: { contentType: 'image/jpeg', cacheControl: 'public, max-age=300' }
})
])
const origin = publicOrigin(request, env)
return json({
cardId: id,
shareUrl: `${origin}/s/${id}`,
viewUrl: `${origin}/v/${id}`,
expiresAt
})
}
const serveAsset = async (env, id, kind) => {
const card = await readCard(env, id)
if (!card) return new Response('Not found', { status: 404 })
const object = await env.REPORTS.get(kind === 'thumbnail' ? thumbnailKey(id) : imageKey(id))
if (!object) return new Response('Not found', { status: 404 })
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('x-content-type-options', 'nosniff')
headers.set('cache-control', kind === 'thumbnail' ? 'public, max-age=300' : 'private, max-age=60')
return new Response(object.body, { headers })
}
const sharePage = (request, env, card) => {
const origin = publicOrigin(request, env)
const viewUrl = `${origin}/v/${card.id}`
const cardLinkUrl = `${origin}/l/${card.id}`
const imageUrl = `${origin}/a/${card.id}/thumbnail`
const share = { title: card.title, desc: card.description, link: cardLinkUrl, imgUrl: imageUrl }
return new Response(
`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta property="og:title" content="${escapeHtml(card.title)}">
<meta property="og:description" content="${escapeHtml(card.description)}">
<meta property="og:image" content="${escapeHtml(imageUrl)}">
<title>${escapeHtml(card.title)}</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f3f6f5;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{min-height:100vh;padding:48px 24px;display:flex;align-items:center;justify-content:center}
.card{width:min(100%,440px);background:#fff;border-radius:24px;padding:30px;box-shadow:0 18px 60px rgba(23,32,29,.10);text-align:center}
.arrow{font-size:52px;color:#16a66a;transform:rotate(-20deg);margin:-10px 0 12px}
h1{font-size:23px;margin:0 0 12px}.desc{color:#61706a;line-height:1.7;margin:0 0 28px}
.hint{background:#ecf8f2;border:1px solid #cdebdc;border-radius:16px;padding:18px;line-height:1.7}
.open{display:inline-block;margin-top:22px;color:#08794c;text-decoration:none;font-weight:650}
#status{font-size:13px;color:#7f8d87;margin-top:18px}
</style>
</head>
<body><main><section class="card">
<div class="arrow">↗</div>
<h1>点击右上角 ··· 分享</h1>
<p class="desc">${escapeHtml(card.description)}</p>
<div class="hint">发送给好友或群聊后,将显示为标题、描述和缩略图组成的微信卡片。</div>
<a class="open" href="${escapeHtml(viewUrl)}">先查看完整日报</a>
<p id="status">正在准备微信分享信息…</p>
</section></main>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
const share=${safeJson(share)};
const status=document.getElementById('status');
fetch('/api/wx-signature?url='+encodeURIComponent(location.href.split('#')[0]))
.then(r=>r.json().then(data=>({ok:r.ok,data})))
.then(({ok,data})=>{
if(!ok) throw new Error(data.error||'签名失败');
wx.config({...data,debug:false,jsApiList:['updateAppMessageShareData','updateTimelineShareData']});
wx.ready(()=>{
wx.updateAppMessageShareData({...share,success:()=>status.textContent='分享卡片已准备好'});
wx.updateTimelineShareData({title:share.title,link:share.link,imgUrl:share.imgUrl});
status.textContent='分享卡片已准备好';
});
wx.error(err=>{status.textContent='微信分享配置失败:'+(err.errMsg||'未知错误')});
})
.catch(err=>{status.textContent='微信分享配置失败:'+err.message});
</script></body></html>`,
{
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy':
"default-src 'self'; script-src 'self' 'unsafe-inline' https://res.wx.qq.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
}
}
)
}
const viewPage = (env, card) => {
const origin = String(env.PUBLIC_ORIGIN).replace(/\/+$/, '')
return new Response(
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(card.title)}</title><style>*{box-sizing:border-box}body{margin:0;background:#eef2f0;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{padding:20px;background:#fff;position:sticky;top:0;box-shadow:0 1px 8px #0001}h1{font-size:18px;margin:0 0 6px}p{margin:0;color:#68746f;font-size:13px}.image{display:block;width:min(100%,900px);height:auto;margin:20px auto;background:#fff}</style></head><body><header><h1>${escapeHtml(card.title)}</h1><p>${escapeHtml(card.description)} · 有效期至 ${escapeHtml(card.expiresAt.slice(0, 10))}</p></header><img class="image" src="${origin}/a/${card.id}/report" alt="${escapeHtml(card.title)}"></body></html>`,
{
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy': "default-src 'self'; img-src 'self'; style-src 'unsafe-inline'"
}
}
)
}
const cachedWechatValue = async (cacheKey, ttl, loader) => {
const cache = caches.default
const request = new Request(`https://wechat-cache.invalid/${cacheKey}`)
const cached = await cache.match(request)
if (cached) return cached.json()
const value = await loader()
await cache.put(request, json(value, { headers: { 'cache-control': `public, max-age=${ttl}` } }))
return value
}
const getAccessToken = (env) =>
cachedWechatValue(`access-token/${env.WECHAT_APP_ID}`, 6900, async () => {
const url = new URL('https://api.weixin.qq.com/cgi-bin/token')
url.searchParams.set('grant_type', 'client_credential')
url.searchParams.set('appid', env.WECHAT_APP_ID)
url.searchParams.set('secret', env.WECHAT_APP_SECRET)
const data = await fetch(url).then((response) => response.json())
if (!data.access_token) throw new Error(data.errmsg || '无法获取 access_token')
return { value: data.access_token }
})
const getTicket = async (env) => {
const token = await getAccessToken(env)
return cachedWechatValue(`jsapi-ticket/${env.WECHAT_APP_ID}`, 6900, async () => {
const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket')
url.searchParams.set('access_token', token.value)
url.searchParams.set('type', 'jsapi')
const data = await fetch(url).then((response) => response.json())
if (!data.ticket) throw new Error(data.errmsg || '无法获取 jsapi_ticket')
return { value: data.ticket }
})
}
const sha1 = async (value) => {
const digest = await crypto.subtle.digest('SHA-1', encoder.encode(value))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}
const signature = async (request, env) => {
if (!env.WECHAT_APP_ID || !env.WECHAT_APP_SECRET) {
return json({ error: '微信 JS-SDK 尚未配置' }, { status: 503 })
}
const pageUrl = new URL(request.url).searchParams.get('url')
if (!pageUrl) return json({ error: '缺少签名 URL' }, { status: 400 })
const parsed = new URL(pageUrl)
if (parsed.origin !== publicOrigin(request, env)) {
return json({ error: '只能签名当前分享域名' }, { status: 400 })
}
const ticket = await getTicket(env)
const nonceStr = crypto.randomUUID().replaceAll('-', '')
const timestamp = Math.floor(Date.now() / 1000)
const source = `jsapi_ticket=${ticket.value}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${pageUrl}`
return json({
appId: env.WECHAT_APP_ID,
timestamp,
nonceStr,
signature: await sha1(source)
})
}
const router = async (request, env) => {
const url = new URL(request.url)
const verificationContent = WECHAT_DOMAIN_VERIFICATION.get(url.pathname.slice(1))
if (request.method === 'GET' && verificationContent) {
return new Response(verificationContent, {
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=300',
'x-content-type-options': 'nosniff'
}
})
}
if (request.method === 'GET' && url.pathname === '/health') {
return json({
ok: true,
service: 'wechatexplorer-share-card',
storage: env.REPORTS ? 'ready' : 'unavailable'
})
}
if (!env.REPORTS && (url.pathname === '/api/cards' || /^\/(s|l|v|a)\//i.test(url.pathname))) {
return storageUnavailable()
}
if (request.method === 'POST' && url.pathname === '/api/cards') return createCard(request, env)
if (request.method === 'GET' && url.pathname === '/api/wx-signature') {
try {
return await signature(request, env)
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 502 }
)
}
}
const match = url.pathname.match(/^\/(s|l|v|a)\/([0-9a-f-]{36})(?:\/(report|thumbnail))?$/i)
if (!match) return new Response('Not found', { status: 404 })
const [, route, id, asset] = match
if (route === 'a') return serveAsset(env, id, asset)
const card = await readCard(env, id)
if (!card) return new Response('卡片不存在或已过期', { status: 404 })
if (route === 'l') {
return Response.redirect(`${publicOrigin(request, env)}/v/${card.id}`, 302)
}
return route === 's' ? sharePage(request, env, card) : viewPage(env, card)
}
const cleanup = async (env) => {
let cursor
do {
const listed = await env.REPORTS.list({ prefix: 'cards/', cursor, include: ['httpMetadata'] })
const metadataObjects = listed.objects.filter((object) => object.key.endsWith('/card.json'))
for (const item of metadataObjects) {
const object = await env.REPORTS.get(item.key)
if (!object) continue
const card = JSON.parse(await object.text())
if (Date.parse(card.expiresAt) <= Date.now()) await deleteCard(env, card.id)
}
cursor = listed.truncated ? listed.cursor : undefined
} while (cursor)
}
export default {
fetch: (request, env) => router(request, env),
scheduled: (_controller, env, ctx) => ctx.waitUntil(cleanup(env))
}
export { escapeHtml, sha1 }
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import worker, { escapeHtml, sha1 } from '../src/index.js'
class MemoryR2Object {
constructor(value, metadata = {}) {
this.value = value
this.metadata = metadata
}
async text() {
return new TextDecoder().decode(this.value)
}
get body() {
return this.value
}
writeHttpMetadata(headers) {
if (this.metadata.contentType) headers.set('content-type', this.metadata.contentType)
if (this.metadata.cacheControl) headers.set('cache-control', this.metadata.cacheControl)
}
}
class MemoryR2 {
objects = new Map()
async put(key, value, options = {}) {
const bytes =
typeof value === 'string' ? new TextEncoder().encode(value) : new Uint8Array(value)
this.objects.set(key, new MemoryR2Object(bytes, options.httpMetadata))
}
async get(key) {
return this.objects.get(key) || null
}
async delete(keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) this.objects.delete(key)
}
}
const env = () => ({
REPORTS: new MemoryR2(),
UPLOAD_TOKEN: 'test-upload-token-that-is-long-enough',
PUBLIC_ORIGIN: 'https://share.example.com',
DEFAULT_EXPIRY_DAYS: '7'
})
test('requires the upload bearer token', async () => {
const response = await worker.fetch(
new Request('https://share.example.com/api/cards', { method: 'POST', body: '{}' }),
env()
)
assert.equal(response.status, 401)
})
test('returns a controlled error when R2 is not configured', async () => {
const response = await worker.fetch(
new Request('https://share.example/s/d9069d5a-d1a0-44fc-a983-2602c3f1cb94'),
{ PUBLIC_ORIGIN: 'https://share.example' }
)
assert.equal(response.status, 503)
assert.match(await response.text(), /图片存储服务尚未启用/)
})
test('creates an expiring card and serves only its random assets', async () => {
const testEnv = env()
const response = await worker.fetch(
new Request('https://share.example.com/api/cards', {
method: 'POST',
headers: {
authorization: `Bearer ${testEnv.UPLOAD_TOKEN}`,
'content-type': 'application/json'
},
body: JSON.stringify({
title: '技术交流群日报',
description: '今日群聊总结',
imageBase64: Buffer.from('png-data').toString('base64'),
thumbnailBase64: Buffer.from('jpeg-data').toString('base64')
})
}),
testEnv
)
assert.equal(response.status, 200)
const card = await response.json()
assert.match(card.cardId, /^[0-9a-f-]{36}$/)
assert.equal(card.shareUrl, `https://share.example.com/s/${card.cardId}`)
const page = await worker.fetch(new Request(card.shareUrl), testEnv)
assert.equal(page.status, 200)
const pageHtml = await page.text()
assert.match(pageHtml, /updateAppMessageShareData/)
assert.match(pageHtml, new RegExp(`/l/${card.cardId}`))
const link = await worker.fetch(
new Request(`https://share.example.com/l/${card.cardId}`),
testEnv
)
assert.equal(link.status, 302)
assert.equal(link.headers.get('location'), `https://share.example.com/v/${card.cardId}`)
const asset = await worker.fetch(
new Request(`https://share.example.com/a/${card.cardId}/thumbnail`),
testEnv
)
assert.equal(asset.status, 200)
assert.equal(await asset.text(), 'jpeg-data')
})
test('escapes untrusted card metadata and produces the expected SHA-1', async () => {
assert.equal(
escapeHtml(`<img src=x onerror="alert('x')">&`),
'&lt;img src=x onerror=&quot;alert(&#39;x&#39;)&quot;&gt;&amp;'
)
assert.equal(await sha1('abc'), 'a9993e364706816aba3e25717850c26c9cd0d89d')
})
test('removes expired cards when they are requested', async () => {
const testEnv = env()
const id = '11111111-1111-4111-8111-111111111111'
await testEnv.REPORTS.put(
`cards/${id}/card.json`,
JSON.stringify({
id,
title: 'expired',
description: '',
expiresAt: new Date(Date.now() - 1000).toISOString()
})
)
await testEnv.REPORTS.put(`cards/${id}/report.png`, 'image')
await testEnv.REPORTS.put(`cards/${id}/thumbnail.jpg`, 'thumb')
const response = await worker.fetch(new Request(`https://share.example.com/s/${id}`), testEnv)
assert.equal(response.status, 404)
assert.equal(testEnv.REPORTS.objects.size, 0)
})
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"r2_buckets": [
{
"binding": "REPORTS",
"bucket_name": "wechatexplorer-share-reports"
}
],
"triggers": {
"crons": ["17 3 * * *"]
},
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
@@ -0,0 +1,16 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
+16
View File
@@ -135,6 +135,12 @@ import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
import { AiSearchPipelineService } from './services/ai-search-pipeline-service' import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper' import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
import { runFirstLaunchMigration } from './app-data-migration' import { runFirstLaunchMigration } from './app-data-migration'
import { WechatShareConfigStore } from './wechat-share-config-store'
import { WechatShareCardService } from './wechat-share-card-service'
import type {
PublishWechatShareCardRequest,
WechatShareServiceConfig
} from '../shared/wechat-share-card'
// electron-vite can close the child's stdout/stderr after spawning Electron. // electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -154,6 +160,8 @@ const imageKeyConfigService = new ImageKeyConfigService()
const aiProviderService = new AIProviderService() const aiProviderService = new AIProviderService()
const keyServiceMac = new KeyServiceMac() const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin() const keyServiceWin = new KeyServiceWin()
const wechatShareConfigStore = new WechatShareConfigStore()
const wechatShareCardService = new WechatShareCardService(wechatShareConfigStore)
let tray: Tray | null = null let tray: Tray | null = null
let recallArchiveMonitor: RecallArchiveMonitor | null = null let recallArchiveMonitor: RecallArchiveMonitor | null = null
let recallProtectionGeneration = 0 let recallProtectionGeneration = 0
@@ -1201,6 +1209,14 @@ app.whenReady().then(async () => {
} }
}) })
ipcMain.handle('wechat-share:getConfig', async () => wechatShareConfigStore.status())
ipcMain.handle('wechat-share:saveConfig', async (_, config: WechatShareServiceConfig) =>
wechatShareConfigStore.save(config)
)
ipcMain.handle('wechat-share:publish', async (_, request: PublishWechatShareCardRequest) =>
wechatShareCardService.publish(request)
)
ipcMain.handle( ipcMain.handle(
'db:getVoiceData', 'db:getVoiceData',
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => { async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
+3
View File
@@ -81,6 +81,7 @@ const normalizeRecord = async (
const pngStatus = await fileStatus(record.pngPath) const pngStatus = await fileStatus(record.pngPath)
return { return {
...record, ...record,
textModelName: record.textModelName || record.modelName,
jsonPath, jsonPath,
htmlStatus, htmlStatus,
pngStatus, pngStatus,
@@ -181,6 +182,8 @@ export async function saveGeneratedReport(
pngStatus: savedPngPath ? 'ready' : 'missing', pngStatus: savedPngPath ? 'ready' : 'missing',
imageSize: await readPngSize(savedPngPath), imageSize: await readPngSize(savedPngPath),
duration: request.duration, duration: request.duration,
textModelName: request.textModelName || request.modelName,
imageModelName: request.imageModelName,
modelName: request.modelName, modelName: request.modelName,
tokenUsage: request.tokenUsage, tokenUsage: request.tokenUsage,
fileSize: { fileSize: {
+55
View File
@@ -9,6 +9,7 @@ import type {
AIProviderSummary, AIProviderSummary,
AiSearchProviderStatus, AiSearchProviderStatus,
AIRuntimeModelConfig, AIRuntimeModelConfig,
AIVisionRuntimeConfig,
AIVisionTestRequest, AIVisionTestRequest,
AIVisionTestResult, AIVisionTestResult,
LegacyAIConfig LegacyAIConfig
@@ -74,6 +75,60 @@ export class AIProviderService {
} }
} }
getVisionRuntimeConfig(): AIVisionRuntimeConfig {
const result = this.list()
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
const defaultModel = defaultProvider?.models.find(
(item) => item.id === defaultProvider.defaultModel
)
if (
defaultProvider &&
defaultModel &&
(defaultProvider.hasApiKey || !needsApiKey(defaultProvider)) &&
(defaultModel.capabilities.vision || defaultModel.capabilities.ocr)
) {
return {
providerId: defaultProvider.id,
providerName: defaultProvider.name,
model: defaultModel.id,
modelName: defaultModel.name || defaultModel.id,
configured: true,
status: defaultProvider.status,
timeoutMs: defaultProvider.advanced.timeoutMs,
source: 'default-model'
}
}
for (const provider of result.providers) {
if (!provider.hasApiKey && needsApiKey(provider)) continue
const model =
provider.models.find(
(item) =>
item.id === provider.defaultModel && (item.capabilities.vision || item.capabilities.ocr)
) || provider.models.find((item) => item.capabilities.vision || item.capabilities.ocr)
if (!model) continue
return {
providerId: provider.id,
providerName: provider.name,
model: model.id,
modelName: model.name || model.id,
configured: true,
status: provider.status,
timeoutMs: provider.advanced.timeoutMs,
source: 'vision-capability'
}
}
return {
providerName: '尚未配置',
model: '',
modelName: '尚未验证图片理解模型',
configured: false,
status: 'untested',
source: 'unavailable'
}
}
getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus { getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus {
const result = this.list() const result = this.list()
const provider = const provider =
+42 -17
View File
@@ -3,7 +3,7 @@
// //
// 设计原则: // 设计原则:
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果) // 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
// 2. 同图(imageHash)走缓存,绝不重复调 AI // 2. 同图(imageHash)在 10 分钟内走缓存,过期后重新调 AI
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报) // 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI // 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
@@ -24,6 +24,7 @@ import type {
} from '../../shared/image-insight' } from '../../shared/image-insight'
import { import {
calculateImageHeatScore, calculateImageHeatScore,
isFreshImageInsight,
isHotImageCandidate isHotImageCandidate
} from '../../shared/image-insight' } from '../../shared/image-insight'
@@ -46,6 +47,13 @@ export interface ImageCandidateInput {
interface ProviderServiceLike { interface ProviderServiceLike {
list(): ProviderSummaryLike list(): ProviderSummaryLike
getVisionRuntimeConfig(): {
providerId?: string
providerName: string
model: string
modelName: string
configured: boolean
}
analyzeImage( analyzeImage(
messages: Array<{ messages: Array<{
role: string role: string
@@ -81,7 +89,7 @@ interface ProviderSummaryLike {
class ImageInsightService { class ImageInsightService {
private providerService: ProviderServiceLike | null = null private providerService: ProviderServiceLike | null = null
private decryptService: DecryptServiceLike | null = null private decryptService: DecryptServiceLike | null = null
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */ /** 最近一次实际使用的视觉 provider/model,仅用于写入分析元数据 */
private runtimeProviderId: string | undefined = undefined private runtimeProviderId: string | undefined = undefined
private runtimeModelId: string | undefined = undefined private runtimeModelId: string | undefined = undefined
@@ -97,13 +105,11 @@ class ImageInsightService {
this.runtimeProviderId, this.runtimeProviderId,
this.runtimeModelId this.runtimeModelId
) )
// 读取默认 provider/model(后续 analyze 时使用) // 读取当前视觉 provider/model(后续 analyze 时仍会刷新,避免配置变化后继续用旧模型)
try { try {
const list = deps.providerService.list() const runtime = deps.providerService.getVisionRuntimeConfig()
const provider = this.runtimeProviderId = runtime.providerId
list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0] this.runtimeModelId = runtime.model || undefined
this.runtimeProviderId = provider?.id
this.runtimeModelId = provider?.defaultModel
console.log( console.log(
'[ImageInsightService] bind loaded default provider=%s model=%s', '[ImageInsightService] bind loaded default provider=%s model=%s',
this.runtimeProviderId, this.runtimeProviderId,
@@ -145,7 +151,7 @@ class ImageInsightService {
/** /**
* 主入口:分析一张图片。 * 主入口:分析一张图片。
* 1. 通过 imageHash 查缓存,命中即返回 * 1. 通过 imageHash 查 10 分钟缓存,新鲜则返回
* 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回 * 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
* 3. 任意步骤失败:记录日志,返回 success=false,**不抛** * 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
*/ */
@@ -153,8 +159,15 @@ class ImageInsightService {
try { try {
if (!request.force) { if (!request.force) {
const cached = imageInsightsStore.getByHash(request.imageHash) const cached = imageInsightsStore.getByHash(request.imageHash)
if (isFreshImageInsight(cached)) {
return { success: true, insight: cached || undefined, fromCache: true }
}
if (cached) { if (cached) {
return { success: true, insight: cached, fromCache: true } console.log(
'[ImageInsightService] cache expired hash=%s ageMs=%d',
request.imageHash,
Date.now() - Number(cached.updatedAt || 0)
)
} }
} }
@@ -184,11 +197,24 @@ class ImageInsightService {
} }
] ]
const list = this.providerService.list() const runtime =
const provider = request.providerId && request.modelId
list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0] ? {
this.runtimeProviderId = provider?.id providerId: request.providerId,
this.runtimeModelId = provider?.defaultModel model: request.modelId,
configured: true
}
: this.providerService.getVisionRuntimeConfig()
if (!runtime.configured || !runtime.providerId || !runtime.model) {
return { success: false, error: '尚未配置或验证支持图片理解的 AI 模型' }
}
this.runtimeProviderId = runtime.providerId
this.runtimeModelId = runtime.model
console.log(
'[ImageInsightService] analyze using vision provider=%s model=%s',
this.runtimeProviderId,
this.runtimeModelId
)
const result = await this.providerService.analyzeImage(messages, { const result = await this.providerService.analyzeImage(messages, {
providerId: this.runtimeProviderId, providerId: this.runtimeProviderId,
modelId: this.runtimeModelId modelId: this.runtimeModelId
@@ -273,7 +299,7 @@ class ImageInsightService {
heatScore heatScore
} }
const cached = imageInsightsStore.getByHash(hash) const cached = imageInsightsStore.getByHash(hash)
if (cached) candidate.insight = cached if (isFreshImageInsight(cached) && cached) candidate.insight = cached
candidates.push(candidate) candidates.push(candidate)
} }
candidates.sort((a, b) => b.heatScore - a.heatScore) candidates.sort((a, b) => b.heatScore - a.heatScore)
@@ -286,7 +312,6 @@ class ImageInsightService {
listBySession(sessionId: string, limit?: number): ImageInsight[] { listBySession(sessionId: string, limit?: number): ImageInsight[] {
return imageInsightsStore.listBySession(sessionId, limit) return imageInsightsStore.listBySession(sessionId, limit)
} }
} }
export const imageInsightService = new ImageInsightService() export const imageInsightService = new ImageInsightService()
+73
View File
@@ -0,0 +1,73 @@
import { nativeImage } from 'electron'
import fs from 'fs-extra'
import QRCode from 'qrcode'
import type {
PublishWechatShareCardRequest,
PublishWechatShareCardResult
} from '../shared/wechat-share-card'
import { WechatShareConfigStore } from './wechat-share-config-store'
const MAX_REPORT_BYTES = 25 * 1024 * 1024
export class WechatShareCardService {
constructor(private readonly configStore: WechatShareConfigStore) {}
async publish(request: PublishWechatShareCardRequest): Promise<PublishWechatShareCardResult> {
try {
const config = await this.configStore.loadRaw()
if (!config) return { success: false, error: '请先配置微信卡片服务' }
const png = await fs.readFile(request.pngPath)
if (!png.length) return { success: false, error: '日报图片为空' }
if (png.length > MAX_REPORT_BYTES) return { success: false, error: '日报图片不能超过 25 MB' }
const source = nativeImage.createFromBuffer(png)
if (source.isEmpty()) return { success: false, error: '无法读取日报图片' }
const size = source.getSize()
const squareSize = Math.min(size.width, size.height)
const thumbnail = source
.crop({ x: 0, y: 0, width: squareSize, height: squareSize })
.resize({ width: 360, height: 360, quality: 'best' })
.toJPEG(84)
const response = await fetch(`${config.serviceUrl}/api/cards`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.uploadToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: request.title.trim().slice(0, 64),
description: request.description.trim().slice(0, 120),
expiresInDays: Math.max(1, Math.min(30, request.expiresInDays || 7)),
imageBase64: png.toString('base64'),
thumbnailBase64: thumbnail.toString('base64')
}),
signal: AbortSignal.timeout(90_000)
})
const payload = (await response.json().catch(() => ({}))) as {
cardId?: string
shareUrl?: string
viewUrl?: string
expiresAt?: string
error?: string
}
if (!response.ok || !payload.shareUrl) {
return { success: false, error: payload.error || `卡片服务返回 HTTP ${response.status}` }
}
return {
success: true,
cardId: payload.cardId,
shareUrl: payload.shareUrl,
viewUrl: payload.viewUrl,
expiresAt: payload.expiresAt,
qrCodeDataUrl: await QRCode.toDataURL(payload.shareUrl, {
width: 360,
margin: 2,
errorCorrectionLevel: 'M'
})
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type {
WechatShareServiceConfig,
WechatShareServiceConfigResult
} from '../shared/wechat-share-card'
const normalizeUrl = (value: string): string => value.trim().replace(/\/+$/, '')
const validate = (config: WechatShareServiceConfig): string | null => {
try {
const url = new URL(normalizeUrl(config.serviceUrl))
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) {
return '卡片服务必须使用 HTTPS'
}
} catch {
return '卡片服务地址无效'
}
if (config.uploadToken.trim().length < 24) return '上传密钥至少需要 24 个字符'
return null
}
export class WechatShareConfigStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'wechat-share-service.bin')
}
async loadRaw(): Promise<WechatShareServiceConfig | null> {
if (!(await fs.pathExists(this.filePath))) return null
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
const encrypted = await fs.readFile(this.filePath)
const parsed = JSON.parse(safeStorage.decryptString(encrypted)) as WechatShareServiceConfig
const error = validate(parsed)
if (error) throw new Error(error)
return { serviceUrl: normalizeUrl(parsed.serviceUrl), uploadToken: parsed.uploadToken.trim() }
}
async status(): Promise<WechatShareServiceConfigResult> {
try {
const config = await this.loadRaw()
return {
success: true,
configured: Boolean(config),
serviceUrl: config?.serviceUrl
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async save(config: WechatShareServiceConfig): Promise<WechatShareServiceConfigResult> {
const normalized = {
serviceUrl: normalizeUrl(config.serviceUrl),
uploadToken: config.uploadToken.trim()
}
const error = validate(normalized)
if (error) return { success: false, error }
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
await fs.ensureDir(path.dirname(this.filePath))
await fs.writeFile(this.filePath, safeStorage.encryptString(JSON.stringify(normalized)), {
mode: 0o600
})
await fs.chmod(this.filePath, 0o600)
return { success: true, configured: true, serviceUrl: normalized.serviceUrl }
}
}
+13
View File
@@ -79,6 +79,12 @@ import type {
KnowledgeSearchIpcRequest, KnowledgeSearchIpcRequest,
KnowledgeSearchIpcResult KnowledgeSearchIpcResult
} from '../shared/knowledge' } from '../shared/knowledge'
import type {
PublishWechatShareCardRequest,
PublishWechatShareCardResult,
WechatShareServiceConfig,
WechatShareServiceConfigResult
} from '../shared/wechat-share-card'
export type ParsedContent = export type ParsedContent =
| { type: 'text'; content: string } | { type: 'text'; content: string }
@@ -327,6 +333,13 @@ declare global {
) => Promise<UpdateGeneratedReportTemplateResult> ) => Promise<UpdateGeneratedReportTemplateResult>
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult> deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getWechatShareConfig: () => Promise<WechatShareServiceConfigResult>
saveWechatShareConfig: (
config: WechatShareServiceConfig
) => Promise<WechatShareServiceConfigResult>
publishWechatShareCard: (
request: PublishWechatShareCardRequest
) => Promise<PublishWechatShareCardResult>
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult> getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment> getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
readDatabaseKeyClipboard: () => Promise<{ readDatabaseKeyClipboard: () => Promise<{
+9
View File
@@ -54,6 +54,10 @@ import type {
KnowledgeSearchIpcRequest, KnowledgeSearchIpcRequest,
KnowledgeSearchIpcResult KnowledgeSearchIpcResult
} from '../shared/knowledge' } from '../shared/knowledge'
import type {
PublishWechatShareCardRequest,
WechatShareServiceConfig
} from '../shared/wechat-share-card'
// 渲染器的自定义 API // 渲染器的自定义 API
const api = { const api = {
@@ -224,6 +228,11 @@ const api = {
deleteGeneratedReport: (reportId: string) => deleteGeneratedReport: (reportId: string) =>
ipcRenderer.invoke('report:deleteGenerated', reportId), ipcRenderer.invoke('report:deleteGenerated', reportId),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getWechatShareConfig: () => ipcRenderer.invoke('wechat-share:getConfig'),
saveWechatShareConfig: (config: WechatShareServiceConfig) =>
ipcRenderer.invoke('wechat-share:saveConfig', config),
publishWechatShareCard: (request: PublishWechatShareCardRequest) =>
ipcRenderer.invoke('wechat-share:publish', request),
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot), getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'), getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'), readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
+132 -8
View File
@@ -6,7 +6,11 @@ import { ApiWorkspace } from './features/api-center/ApiWorkspace'
import { SettingsWorkspace } from './features/settings/SettingsWorkspace' import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace' import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
import type { SettingsCategoryId } from './features/settings/model/types' import type { SettingsCategoryId } from './features/settings/model/types'
import type { AIRuntimeModelConfig } from '../../shared/ai-provider' import type {
AIProviderSummary,
AIRuntimeModelConfig,
ReportModelChoice
} from '../../shared/ai-provider'
import { AppPage } from './components/layout/navigation' import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace' import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar' import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
@@ -73,6 +77,51 @@ const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20 const EXPORT_PREVIEW_LIMIT = 20
const REPORT_TEXT_MODEL_STORAGE_KEY = 'group_report_text_model'
const REPORT_VISION_MODEL_STORAGE_KEY = 'group_report_vision_model'
const reportModelKey = (model: { providerId?: string; model: string }): string =>
model.providerId && model.model ? `${model.providerId}::${model.model}` : ''
const reportProviderConfigured = (provider: AIProviderSummary): boolean =>
Boolean(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
const reportModelChoices = (
providers: AIProviderSummary[],
capability: 'chat' | 'vision'
): ReportModelChoice[] =>
providers.flatMap((provider) => {
if (!reportProviderConfigured(provider)) return []
return provider.models
.filter((model) =>
capability === 'chat'
? model.capabilities.chat
: model.capabilities.vision || model.capabilities.ocr
)
.map((model) => ({
providerId: provider.id,
providerName: provider.name,
model: model.id,
modelName: model.name || model.id,
configured: true as const,
status: provider.status,
timeoutMs: provider.advanced.timeoutMs
}))
})
const selectReportModel = (
choices: ReportModelChoice[],
storageKey: string,
fallback: { providerId?: string; model: string }
): ReportModelChoice | undefined => {
const savedKey = localStorage.getItem(storageKey) || ''
const fallbackKey = reportModelKey(fallback)
return (
choices.find((choice) => reportModelKey(choice) === savedKey) ||
choices.find((choice) => reportModelKey(choice) === fallbackKey) ||
choices[0]
)
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true if (left === right) return true
if (left.length !== right.length) return false if (left.length !== right.length) return false
@@ -209,6 +258,16 @@ function App(): React.ReactElement {
configured: false, configured: false,
status: 'untested' status: 'untested'
}) })
const [reportTextModelConfig, setReportTextModelConfig] = useState<AiModelConfig>({
providerName: '尚未配置',
model: '',
modelName: '尚未选择模型',
configured: false,
status: 'untested'
})
const [aiVisionModelConfig, setAiVisionModelConfig] = useState<ReportModelChoice>()
const [reportTextModelOptions, setReportTextModelOptions] = useState<ReportModelChoice[]>([])
const [reportVisionModelOptions, setReportVisionModelOptions] = useState<ReportModelChoice[]>([])
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null) const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => { const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
@@ -336,7 +395,25 @@ function App(): React.ReactElement {
localStorage.removeItem('ai_model') localStorage.removeItem('ai_model')
} }
} }
setAiModelConfig(await window.api.getAIRuntimeConfig()) const [runtime, visionRuntime, providerList] = await Promise.all([
window.api.getAIRuntimeConfig(),
window.api.getAIVisionRuntimeConfig(),
window.api.listAIProviders()
])
const providers = providerList.success ? providerList.providers : []
const textChoices = reportModelChoices(providers, 'chat')
const visionChoices = reportModelChoices(providers, 'vision')
const selectedText = selectReportModel(textChoices, REPORT_TEXT_MODEL_STORAGE_KEY, runtime)
const selectedVision = selectReportModel(
visionChoices,
REPORT_VISION_MODEL_STORAGE_KEY,
visionRuntime
)
setReportTextModelOptions(textChoices)
setReportVisionModelOptions(visionChoices)
setAiModelConfig(runtime)
setReportTextModelConfig(selectedText || runtime)
setAiVisionModelConfig(selectedVision)
} catch (error) { } catch (error) {
console.warn('[AI Provider] 配置加载失败:', error) console.warn('[AI Provider] 配置加载失败:', error)
} }
@@ -349,7 +426,8 @@ function App(): React.ReactElement {
sourceContact: reportSourceContact, sourceContact: reportSourceContact,
summaryDateRange, summaryDateRange,
summaryMessageTypes, summaryMessageTypes,
modelConfig: aiModelConfig modelConfig: reportTextModelConfig,
visionModelConfig: aiVisionModelConfig
}) })
const lastCapturedReportKeyRef = React.useRef('') const lastCapturedReportKeyRef = React.useRef('')
@@ -1496,7 +1574,10 @@ function App(): React.ReactElement {
htmlPath: reportGeneration.reportPaths?.htmlPath, htmlPath: reportGeneration.reportPaths?.htmlPath,
pngPath: reportGeneration.reportPaths?.pngPath, pngPath: reportGeneration.reportPaths?.pngPath,
duration: reportGeneration.generationMetadata.durationMs, duration: reportGeneration.generationMetadata.durationMs,
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model, textModelName:
reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
imageModelName: aiVisionModelConfig?.modelName || aiVisionModelConfig?.model,
modelName: reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
tokenUsage: reportGeneration.generationMetadata.tokenUsage, tokenUsage: reportGeneration.generationMetadata.tokenUsage,
generationLogs: reportGeneration.generationMetadata.generationLogs, generationLogs: reportGeneration.generationMetadata.generationLogs,
reportSnapshot, reportSnapshot,
@@ -1521,7 +1602,10 @@ function App(): React.ReactElement {
void saveReport() void saveReport()
}, [ }, [
aiModelConfig.model, aiVisionModelConfig?.model,
aiVisionModelConfig?.modelName,
reportTextModelConfig.model,
reportTextModelConfig.modelName,
reportGeneration.generatedImage, reportGeneration.generatedImage,
reportGeneration.generationMetadata, reportGeneration.generationMetadata,
reportGeneration.phase, reportGeneration.phase,
@@ -1689,7 +1773,10 @@ function App(): React.ReactElement {
sourceContact={reportSourceContact} sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange} summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes} summaryMessageTypes={summaryMessageTypes}
modelConfig={aiModelConfig} modelConfig={reportTextModelConfig}
visionModelConfig={aiVisionModelConfig}
textModelOptions={reportTextModelOptions}
visionModelOptions={reportVisionModelOptions}
rangeMessageCount={reportGeneration.rangeMessages.length} rangeMessageCount={reportGeneration.rangeMessages.length}
reportMessageCount={reportGeneration.reportMessages.length} reportMessageCount={reportGeneration.reportMessages.length}
messageTypeCounts={reportGeneration.messageTypeCounts} messageTypeCounts={reportGeneration.messageTypeCounts}
@@ -1702,6 +1789,14 @@ function App(): React.ReactElement {
onSummaryDateRangeChange={setSummaryDateRange} onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes} onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={openModelSettings} onOpenModelSettings={openModelSettings}
onTextModelChange={(model) => {
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
setReportTextModelConfig(model)
}}
onVisionModelChange={(model) => {
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
setAiVisionModelConfig(model)
}}
onGenerate={() => { onGenerate={() => {
reportGeneration.resetGenerationStatus() reportGeneration.resetGenerationStatus()
void reportGeneration.generate() void reportGeneration.generate()
@@ -1726,7 +1821,7 @@ function App(): React.ReactElement {
preparationProgress={reportGeneration.preparationProgress} preparationProgress={reportGeneration.preparationProgress}
imageInsightSummary={reportGeneration.imageInsightSummary} imageInsightSummary={reportGeneration.imageInsightSummary}
canRetryModelStep={reportGeneration.canRetryModelStep} canRetryModelStep={reportGeneration.canRetryModelStep}
currentModel={aiModelConfig} currentModel={reportTextModelConfig}
onRetry={(model) => void reportGeneration.retry(model)} onRetry={(model) => void reportGeneration.retry(model)}
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()} onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures} onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
@@ -1765,7 +1860,36 @@ function App(): React.ReactElement {
onContactsChange={setContacts} onContactsChange={setContacts}
onFilteredContactsChange={setFilteredContacts} onFilteredContactsChange={setFilteredContacts}
onReturnToLogin={handleReturnToLogin} onReturnToLogin={handleReturnToLogin}
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)} onAIRuntimeChange={(config: AIRuntimeModelConfig) => {
setAiModelConfig(config)
void Promise.all([
window.api.getAIVisionRuntimeConfig(),
window.api.listAIProviders()
])
.then(([visionRuntime, providerList]) => {
const providers = providerList.success ? providerList.providers : []
const textChoices = reportModelChoices(providers, 'chat')
const visionChoices = reportModelChoices(providers, 'vision')
setReportTextModelOptions(textChoices)
setReportVisionModelOptions(visionChoices)
setReportTextModelConfig(
(current) =>
selectReportModel(
textChoices,
REPORT_TEXT_MODEL_STORAGE_KEY,
current.configured ? current : config
) || config
)
setAiVisionModelConfig((current) =>
selectReportModel(
visionChoices,
REPORT_VISION_MODEL_STORAGE_KEY,
current || visionRuntime
)
)
})
.catch(() => undefined)
}}
onNotice={setReportNotice} onNotice={setReportNotice}
onOpenSettings={openSettings} onOpenSettings={openSettings}
onAppearanceChange={handleAppearanceChange} onAppearanceChange={handleAppearanceChange}
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { Contact } from '../../../../shared/types' import { Contact } from '../../../../shared/types'
import type { ReportModelChoice } from '../../../../shared/ai-provider'
import { import {
AiModelConfig, AiModelConfig,
RangeMessageState, RangeMessageState,
@@ -22,6 +23,9 @@ interface AiReportWorkspaceProps {
summaryDateRange: SummaryDateRange summaryDateRange: SummaryDateRange
summaryMessageTypes: SummaryMessageType[] summaryMessageTypes: SummaryMessageType[]
modelConfig: AiModelConfig modelConfig: AiModelConfig
visionModelConfig?: ReportModelChoice
textModelOptions: ReportModelChoice[]
visionModelOptions: ReportModelChoice[]
rangeMessageCount: number rangeMessageCount: number
reportMessageCount: number reportMessageCount: number
messageTypeCounts: Record<SummaryMessageType, number> messageTypeCounts: Record<SummaryMessageType, number>
@@ -34,6 +38,8 @@ interface AiReportWorkspaceProps {
onSummaryDateRangeChange: (value: SummaryDateRange) => void onSummaryDateRangeChange: (value: SummaryDateRange) => void
onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void
onOpenModelSettings: () => void onOpenModelSettings: () => void
onTextModelChange: (model: ReportModelChoice) => void
onVisionModelChange: (model: ReportModelChoice) => void
onGenerate: () => void onGenerate: () => void
onCloseResult: () => void onCloseResult: () => void
onCopyImage: () => Promise<{ success: boolean; error?: string }> onCopyImage: () => Promise<{ success: boolean; error?: string }>
@@ -69,6 +75,9 @@ export function AiReportWorkspace({
summaryDateRange, summaryDateRange,
summaryMessageTypes, summaryMessageTypes,
modelConfig, modelConfig,
visionModelConfig,
textModelOptions,
visionModelOptions,
rangeMessageCount, rangeMessageCount,
reportMessageCount, reportMessageCount,
messageTypeCounts, messageTypeCounts,
@@ -81,6 +90,8 @@ export function AiReportWorkspace({
onSummaryDateRangeChange, onSummaryDateRangeChange,
onSummaryMessageTypesChange, onSummaryMessageTypesChange,
onOpenModelSettings, onOpenModelSettings,
onTextModelChange,
onVisionModelChange,
onGenerate, onGenerate,
onCloseResult, onCloseResult,
onCopyImage, onCopyImage,
@@ -171,7 +182,16 @@ export function AiReportWorkspace({
disabled={configDisabled} disabled={configDisabled}
/> />
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} /> <ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} /> <ModelSummary
config={modelConfig}
visionConfig={visionModelConfig}
textModels={textModelOptions}
visionModels={visionModelOptions}
disabled={configDisabled}
onTextModelChange={onTextModelChange}
onVisionModelChange={onVisionModelChange}
onOpenSettings={onOpenModelSettings}
/>
<section className="report-config-section report-timeout-section"> <section className="report-config-section report-timeout-section">
<div> <div>
<h3></h3> <h3></h3>
@@ -1,24 +1,87 @@
import React from 'react' import React from 'react'
import type { ReportModelChoice } from '../../../../shared/ai-provider'
import { AiModelConfig } from '../../hooks/useGroupReportGeneration' import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
interface ModelSummaryProps { interface ModelSummaryProps {
config: AiModelConfig config: AiModelConfig
visionConfig?: ReportModelChoice
textModels: ReportModelChoice[]
visionModels: ReportModelChoice[]
disabled?: boolean
onTextModelChange: (model: ReportModelChoice) => void
onVisionModelChange: (model: ReportModelChoice) => void
onOpenSettings: () => void onOpenSettings: () => void
} }
export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement { const modelKey = (model: { providerId?: string; model: string } | undefined): string =>
const statusText = config.configured ? '配置正常' : '尚未配置' model?.providerId && model.model ? `${model.providerId}::${model.model}` : ''
const optionLabel = (model: ReportModelChoice): string =>
`${model.providerName} · ${model.modelName || model.model}`
export function ModelSummary({
config,
visionConfig,
textModels,
visionModels,
disabled = false,
onTextModelChange,
onVisionModelChange,
onOpenSettings
}: ModelSummaryProps): React.ReactElement {
const changeModel = (
key: string,
models: ReportModelChoice[],
onChange: (model: ReportModelChoice) => void
): void => {
const selected = models.find((model) => modelKey(model) === key)
if (selected) onChange(selected)
}
return ( return (
<section className="report-config-section"> <section className="report-config-section">
<div className="report-model-summary"> <div className="report-model-summary">
<div> <div className="report-model-summary-content">
<h3></h3> <h3></h3>
<p> <div className="report-model-selects">
{config.modelName || config.model || '未选择模型'} · {statusText} <label>
</p> <span></span>
<select
aria-label="文字总结模型"
value={modelKey(config)}
disabled={disabled || !textModels.length}
onChange={(event) => changeModel(event.target.value, textModels, onTextModelChange)}
>
{!textModels.length && <option value=""></option>}
{textModels.map((model) => (
<option key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</option>
))}
</select>
</label>
<label>
<span></span>
<select
aria-label="图片理解模型"
value={modelKey(visionConfig)}
disabled={disabled || !visionModels.length}
onChange={(event) =>
changeModel(event.target.value, visionModels, onVisionModelChange)
}
>
{!visionModels.length && <option value=""></option>}
{visionModels.map((model) => (
<option key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</option>
))}
</select>
</label>
</div> </div>
<button type="button" onClick={onOpenSettings}> <small> 10 </small>
</div>
<button type="button" onClick={onOpenSettings} disabled={disabled}>
</button> </button>
</div> </div>
@@ -108,8 +108,12 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea
<b>{formatDuration(report.duration)}</b> <b>{formatDuration(report.duration)}</b>
</div> </div>
<div> <div>
<span></span> <span></span>
<b>{modelLabel(report.modelName)}</b> <b>{modelLabel(report.textModelName || report.modelName)}</b>
</div>
<div>
<span></span>
<b>{modelLabel(report.imageModelName)}</b>
</div> </div>
<div> <div>
<span>Token </span> <span>Token </span>
@@ -7,6 +7,7 @@ import {
interface ReportToolbarProps { interface ReportToolbarProps {
canCopyImage: boolean canCopyImage: boolean
canReveal: boolean canReveal: boolean
canShare: boolean
canSwitchTemplate: boolean canSwitchTemplate: boolean
currentTemplateId?: SelectableReportTemplateId currentTemplateId?: SelectableReportTemplateId
isSwitchingTemplate: boolean isSwitchingTemplate: boolean
@@ -14,18 +15,21 @@ interface ReportToolbarProps {
onRegenerate: () => void onRegenerate: () => void
onCopyImage: () => void onCopyImage: () => void
onReveal: () => void onReveal: () => void
onShare: () => void
} }
export function ReportToolbar({ export function ReportToolbar({
canCopyImage, canCopyImage,
canReveal, canReveal,
canShare,
canSwitchTemplate, canSwitchTemplate,
currentTemplateId, currentTemplateId,
isSwitchingTemplate, isSwitchingTemplate,
onSwitchTemplate, onSwitchTemplate,
onRegenerate, onRegenerate,
onCopyImage, onCopyImage,
onReveal onReveal,
onShare
}: ReportToolbarProps): React.ReactElement { }: ReportToolbarProps): React.ReactElement {
const [moreOpen, setMoreOpen] = useState(false) const [moreOpen, setMoreOpen] = useState(false)
const [templateOpen, setTemplateOpen] = useState(false) const [templateOpen, setTemplateOpen] = useState(false)
@@ -88,6 +92,9 @@ export function ReportToolbar({
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}> <button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
</button> </button>
<button type="button" disabled={!canShare} onClick={onShare}>
</button>
<div className="report-more-menu" ref={menuRef}> <div className="report-more-menu" ref={menuRef}>
<button type="button" onClick={() => setMoreOpen((open) => !open)}> <button type="button" onClick={() => setMoreOpen((open) => !open)}>
@@ -4,6 +4,7 @@ import { ReportEmptyState } from './ReportEmptyState'
import { ReportToolbar } from './ReportToolbar' import { ReportToolbar } from './ReportToolbar'
import { ReportZoomBar } from './ReportZoomBar' import { ReportZoomBar } from './ReportZoomBar'
import type { SelectableReportTemplateId } from '../../../../shared/report-templates' import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
import { WechatShareCardDialog } from './WechatShareCardDialog'
interface ReportViewerProps { interface ReportViewerProps {
report: GeneratedReportRecord | null report: GeneratedReportRecord | null
@@ -49,6 +50,7 @@ export function ReportViewer({
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [imageError, setImageError] = useState('') const [imageError, setImageError] = useState('')
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false) const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
const [shareDialogOpen, setShareDialogOpen] = useState(false)
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null) const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
const viewportRef = useRef<HTMLDivElement>(null) const viewportRef = useRef<HTMLDivElement>(null)
@@ -57,6 +59,7 @@ export function ReportViewer({
setStatus('') setStatus('')
setImageError('') setImageError('')
setIsSwitchingTemplate(false) setIsSwitchingTemplate(false)
setShareDialogOpen(false)
setZoom(1) setZoom(1)
setFitZoom(1) setFitZoom(1)
setNaturalSize(null) setNaturalSize(null)
@@ -165,6 +168,7 @@ export function ReportViewer({
<ReportToolbar <ReportToolbar
canCopyImage={Boolean(report.generatedImage)} canCopyImage={Boolean(report.generatedImage)}
canReveal={Boolean(report.pngPath || report.htmlPath)} canReveal={Boolean(report.pngPath || report.htmlPath)}
canShare={Boolean(report.pngPath)}
canSwitchTemplate={Boolean( canSwitchTemplate={Boolean(
(report.reportSnapshot && report.reportMetadata) || (report.reportSnapshot && report.reportMetadata) ||
report.reportRenderSnapshot || report.reportRenderSnapshot ||
@@ -176,6 +180,7 @@ export function ReportViewer({
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
onCopyImage={() => void handleCopy()} onCopyImage={() => void handleCopy()}
onReveal={() => void handleReveal()} onReveal={() => void handleReveal()}
onShare={() => setShareDialogOpen(true)}
/> />
</header> </header>
{status && <div className="report-viewer-status">{status}</div>} {status && <div className="report-viewer-status">{status}</div>}
@@ -233,6 +238,14 @@ export function ReportViewer({
onFitPage={fitPage} onFitPage={fitPage}
onActualSize={showActualSize} onActualSize={showActualSize}
/> />
{shareDialogOpen && report.pngPath && (
<WechatShareCardDialog
pngPath={report.pngPath}
initialTitle={`${report.contactName}日报 · ${report.reportDate}`}
initialDescription={`基于 ${report.messageCount} 条群聊消息生成的 AI 日报`}
onClose={() => setShareDialogOpen(false)}
/>
)}
</main> </main>
) )
} }
@@ -0,0 +1,202 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import type { PublishWechatShareCardResult } from '../../../../shared/wechat-share-card'
interface WechatShareCardDialogProps {
pngPath: string
initialTitle: string
initialDescription: string
onClose: () => void
}
export function WechatShareCardDialog({
pngPath,
initialTitle,
initialDescription,
onClose
}: WechatShareCardDialogProps): React.ReactElement {
const [title, setTitle] = useState(initialTitle)
const [description, setDescription] = useState(initialDescription)
const [serviceUrl, setServiceUrl] = useState('https://share.example.com')
const [uploadToken, setUploadToken] = useState('')
const [configured, setConfigured] = useState<boolean | null>(null)
const [editingConfig, setEditingConfig] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState<PublishWechatShareCardResult | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
void window.api.getWechatShareConfig().then((response) => {
setConfigured(Boolean(response.success && response.configured))
if (response.serviceUrl) setServiceUrl(response.serviceUrl)
if (!response.success) setError(response.error || '读取卡片服务配置失败')
})
}, [])
useEffect(() => {
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const closeOnEscape = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !busy) onClose()
}
window.addEventListener('keydown', closeOnEscape)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', closeOnEscape)
}
}, [busy, onClose])
const publish = async (): Promise<void> => {
setBusy(true)
setError('')
try {
if (!configured || editingConfig) {
const saved = await window.api.saveWechatShareConfig({ serviceUrl, uploadToken })
if (!saved.success) {
setError(saved.error || '保存卡片服务配置失败')
return
}
setConfigured(true)
setEditingConfig(false)
}
const published = await window.api.publishWechatShareCard({
pngPath,
title,
description,
expiresInDays: 7
})
if (!published.success) {
setError(published.error || '微信卡片生成失败')
return
}
setResult(published)
} finally {
setBusy(false)
}
}
const copyLink = async (): Promise<void> => {
if (!result?.shareUrl) return
await navigator.clipboard.writeText(result.shareUrl)
setCopied(true)
}
return createPortal(
<div className="wechat-share-dialog-backdrop" role="presentation" onMouseDown={onClose}>
<section
className="wechat-share-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="wechat-share-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<h2 id="wechat-share-title"></h2>
<p> 7 </p>
</div>
<button type="button" className="wechat-share-dialog-close" onClick={onClose}>
×
</button>
</header>
{result?.qrCodeDataUrl ? (
<div className="wechat-share-success">
<img src={result.qrCodeDataUrl} alt="微信分享二维码" />
<h3>使</h3>
<p> ···</p>
{result.expiresAt && (
<small> {new Date(result.expiresAt).toLocaleString('zh-CN')}</small>
)}
<div>
<button type="button" onClick={() => void copyLink()}>
{copied ? '链接已复制' : '复制分享链接'}
</button>
<button type="button" className="primary" onClick={onClose}>
</button>
</div>
</div>
) : (
<div className="wechat-share-form">
<label>
<span></span>
<input
maxLength={64}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
<span></span>
<textarea
maxLength={120}
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
{configured === true && !editingConfig && (
<div className="wechat-share-service-summary">
<div>
<span></span>
<b>{serviceUrl}</b>
</div>
<button type="button" onClick={() => setEditingConfig(true)}>
</button>
</div>
)}
{(configured === false || editingConfig) && (
<div className="wechat-share-service-config">
<h3></h3>
<label>
<span></span>
<input
value={serviceUrl}
onChange={(event) => setServiceUrl(event.target.value)}
/>
</label>
<label>
<span></span>
<input
type="password"
autoComplete="off"
value={uploadToken}
onChange={(event) => setUploadToken(event.target.value)}
placeholder="Cloudflare Worker 的 UPLOAD_TOKEN"
/>
</label>
<p> AppSecret</p>
</div>
)}
<div className="wechat-share-privacy">
R2
</div>
{error && <p className="report-inline-error">{error}</p>}
<footer>
<button type="button" onClick={onClose}>
</button>
<button
type="button"
className="primary"
disabled={
busy ||
configured === null ||
!title.trim() ||
((!configured || editingConfig) && uploadToken.trim().length < 24)
}
onClick={() => void publish()}
>
{busy ? '正在生成卡片…' : '生成二维码'}
</button>
</footer>
</div>
)}
</section>
</div>,
document.body
)
}
@@ -23,6 +23,7 @@ import {
} from '../utils/voice-message-reference' } from '../utils/voice-message-reference'
import type { VoiceModelStatus } from '../../../shared/voice-recognition' import type { VoiceModelStatus } from '../../../shared/voice-recognition'
import { resolveMemberName } from '../../../shared/member-names' import { resolveMemberName } from '../../../shared/member-names'
import type { ReportModelChoice } from '../../../shared/ai-provider'
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference' export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
@@ -86,6 +87,7 @@ interface UseGroupReportGenerationArgs {
summaryDateRange: SummaryDateRange summaryDateRange: SummaryDateRange
summaryMessageTypes: SummaryMessageType[] summaryMessageTypes: SummaryMessageType[]
modelConfig: AiModelConfig modelConfig: AiModelConfig
visionModelConfig?: ReportModelChoice
} }
interface PreparedReportContext { interface PreparedReportContext {
@@ -260,7 +262,8 @@ export function useGroupReportGeneration({
sourceContact, sourceContact,
summaryDateRange, summaryDateRange,
summaryMessageTypes, summaryMessageTypes,
modelConfig modelConfig,
visionModelConfig
}: UseGroupReportGenerationArgs): { }: UseGroupReportGenerationArgs): {
phase: ReportGenerationPhase phase: ReportGenerationPhase
error: string error: string
@@ -464,7 +467,7 @@ export function useGroupReportGeneration({
const pushLog = (log: ReportGenerationLog): void => { const pushLog = (log: ReportGenerationLog): void => {
context.logs.push(log) context.logs.push(log)
setGenerationMetadata({ setGenerationMetadata({
modelName: selectedModel.model, modelName: selectedModel.modelName || selectedModel.model,
generationLogs: [...context.logs] generationLogs: [...context.logs]
}) })
} }
@@ -482,7 +485,7 @@ export function useGroupReportGeneration({
setPhase('requestingModel') setPhase('requestingModel')
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' }) setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
setGenerationMetadata({ setGenerationMetadata({
modelName: selectedModel.model, modelName: selectedModel.modelName || selectedModel.model,
generationLogs: [...context.logs] generationLogs: [...context.logs]
}) })
writeReportLog('info', '调用模型生成日报内容', { writeReportLog('info', '调用模型生成日报内容', {
@@ -619,7 +622,7 @@ export function useGroupReportGeneration({
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
? exportFinishTime - context.startedAt ? exportFinishTime - context.startedAt
: Date.now() - context.startedAt, : Date.now() - context.startedAt,
modelName: selectedModel.model, modelName: selectedModel.modelName || selectedModel.model,
tokenUsage, tokenUsage,
generationLogs: [...context.logs] generationLogs: [...context.logs]
}) })
@@ -681,7 +684,10 @@ export function useGroupReportGeneration({
const logs: ReportGenerationLog[] = [] const logs: ReportGenerationLog[] = []
const pushLog = (log: ReportGenerationLog): void => { const pushLog = (log: ReportGenerationLog): void => {
logs.push(log) logs.push(log)
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [...logs] }) setGenerationMetadata({
modelName: modelConfig.modelName || modelConfig.model,
generationLogs: [...logs]
})
} }
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => { const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
const startedAt = new Date() const startedAt = new Date()
@@ -702,7 +708,10 @@ export function useGroupReportGeneration({
setVoiceTranscriptionProgress(null) setVoiceTranscriptionProgress(null)
setPreparationProgress(null) setPreparationProgress(null)
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY) setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [] }) setGenerationMetadata({
modelName: modelConfig.modelName || modelConfig.model,
generationLogs: []
})
writeReportLog('info', '开始生成群聊日报', { writeReportLog('info', '开始生成群聊日报', {
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName, groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
dateRange: summaryDateRange, dateRange: summaryDateRange,
@@ -736,7 +745,8 @@ export function useGroupReportGeneration({
memberNamePreference memberNamePreference
) )
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', { return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
onProgress: setPreparationProgress onProgress: setPreparationProgress,
visionModel: visionModelConfig
}) })
}) })
@@ -782,7 +792,8 @@ export function useGroupReportGeneration({
summaryDateRange, summaryDateRange,
summaryMessageTypes, summaryMessageTypes,
templateId, templateId,
transcribeSelectedVoiceMessages transcribeSelectedVoiceMessages,
visionModelConfig
]) ])
const retry = useCallback( const retry = useCallback(
+45
View File
@@ -467,12 +467,57 @@
gap: 12px; gap: 12px;
} }
.report-model-summary-content {
min-width: 0;
flex: 1;
}
.report-model-selects {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 8px;
}
.report-model-selects label {
display: grid;
min-width: 0;
gap: 5px;
color: var(--wxex-text-secondary);
font: 12px/17px var(--wxex-font);
}
.report-model-selects select {
width: 100%;
min-width: 0;
height: 34px;
padding: 0 30px 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
.report-model-selects select:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.75;
}
.report-model-summary p { .report-model-summary p {
margin: 4px 0 0; margin: 4px 0 0;
color: var(--wxex-text-secondary); color: var(--wxex-text-secondary);
font: 13px/18px var(--wxex-font); font: 13px/18px var(--wxex-font);
} }
.report-model-summary small {
display: block;
margin-top: 6px;
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
.report-model-summary button, .report-model-summary button,
.report-result-actions button, .report-result-actions button,
.report-task-error button { .report-task-error button {
+213 -1
View File
@@ -245,19 +245,27 @@
flex: 0 0 auto; flex: 0 0 auto;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap;
gap: 16px; gap: 16px;
padding: 18px 22px 12px; padding: 18px 22px 12px;
border-bottom: 1px solid var(--wxex-border); border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-main); background: var(--wxex-bg-main);
} }
.report-viewer-header > div:first-child {
flex: 1 1 220px;
min-width: 180px;
}
.report-viewer-header h1 { .report-viewer-header h1 {
font: 700 20px/27px var(--wxex-font); font: 700 20px/27px var(--wxex-font);
word-break: keep-all;
} }
.report-viewer-toolbar { .report-viewer-toolbar {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 1 auto;
min-width: 0;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
@@ -592,6 +600,15 @@
.report-viewer-header { .report-viewer-header {
flex-direction: column; flex-direction: column;
} }
.report-viewer-header > div:first-child {
width: 100%;
}
.report-viewer-toolbar {
width: 100%;
justify-content: flex-start;
}
} }
/* ============================================================ */ /* ============================================================ */
@@ -1169,3 +1186,198 @@
color: var(--wxex-text-muted); color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font); font: 11px/17px var(--wxex-font);
} }
.wechat-share-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(20, 31, 27, 0.46);
backdrop-filter: blur(8px);
}
.wechat-share-dialog {
width: min(540px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
border: 1px solid rgba(95, 129, 116, 0.2);
border-radius: 22px;
background: #fff;
box-shadow: 0 28px 90px rgba(14, 32, 25, 0.24);
color: #17241f;
font-family: var(--wxex-font);
}
.wechat-share-dialog,
.wechat-share-dialog * {
box-sizing: border-box;
}
.wechat-share-dialog > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 26px 18px;
border-bottom: 1px solid #e8eeeb;
}
.wechat-share-dialog h2,
.wechat-share-dialog h3,
.wechat-share-dialog p {
margin: 0;
}
.wechat-share-dialog header h2 {
color: #17241f;
font-size: 20px;
}
.wechat-share-dialog header p {
margin-top: 6px;
color: #718078;
font-size: 13px;
}
.wechat-share-dialog-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: #f1f5f3;
color: #5f6d67;
font-size: 22px;
line-height: 1;
}
.wechat-share-form {
display: grid;
gap: 18px;
padding: 24px 26px 26px;
}
.wechat-share-form label {
display: grid;
gap: 8px;
}
.wechat-share-form label > span {
color: #33463e;
font-size: 13px;
font-weight: 650;
}
.wechat-share-form input,
.wechat-share-form textarea {
min-width: 0;
max-width: 100%;
width: 100%;
border: 1px solid #cfdbd6;
border-radius: 11px;
background: #fbfdfc;
padding: 11px 13px;
color: #1f2c27;
font: inherit;
outline: none;
}
.wechat-share-form input:focus,
.wechat-share-form textarea:focus {
border-color: #32a978;
box-shadow: 0 0 0 3px rgba(50, 169, 120, 0.12);
}
.wechat-share-service-config {
min-width: 0;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d6e8df;
border-radius: 14px;
background: #f2faf6;
}
.wechat-share-service-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
padding: 13px 15px;
border: 1px solid #dce6e1;
border-radius: 12px;
background: #f7faf8;
}
.wechat-share-service-summary > div {
display: grid;
min-width: 0;
gap: 3px;
}
.wechat-share-service-summary span {
color: #728078;
font-size: 11px;
}
.wechat-share-service-summary b {
overflow: hidden;
color: #2c4138;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.wechat-share-service-summary button {
flex: 0 0 auto;
min-height: 30px;
padding: 0 11px;
}
.wechat-share-service-config h3 {
color: #23523f;
font-size: 14px;
}
.wechat-share-service-config p,
.wechat-share-privacy {
color: #68776f;
font-size: 12px;
line-height: 1.6;
}
.wechat-share-privacy {
padding: 12px 14px;
border-radius: 10px;
background: #f5f7f6;
}
.wechat-share-form > footer,
.wechat-share-success > div {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.wechat-share-form button,
.wechat-share-success button {
min-height: 38px;
padding: 0 17px;
border: 1px solid #ced9d4;
border-radius: 10px;
background: #fff;
color: #34463f;
}
.wechat-share-form button.primary,
.wechat-share-success button.primary {
border-color: #15945e;
background: #15945e;
color: #fff;
}
.wechat-share-form button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.wechat-share-success {
display: grid;
justify-items: center;
gap: 11px;
padding: 28px;
text-align: center;
}
.wechat-share-success > img {
width: 260px;
max-width: 80%;
border: 10px solid #fff;
border-radius: 14px;
box-shadow: 0 10px 35px rgba(27, 59, 46, 0.12);
}
.wechat-share-success h3 {
color: #203029;
font-size: 18px;
}
.wechat-share-success p,
.wechat-share-success small {
color: #6c7b74;
line-height: 1.6;
}
.wechat-share-success > div {
margin-top: 12px;
}
@@ -16,6 +16,7 @@ import type {
ImageCandidateQuery ImageCandidateQuery
} from '../../../shared/image-insight' } from '../../../shared/image-insight'
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight' import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
import type { ReportModelChoice } from '../../../shared/ai-provider'
interface ReportImageReadResult { interface ReportImageReadResult {
success: boolean success: boolean
@@ -93,6 +94,7 @@ export interface ReportPreparationProgress {
export interface BuildGroupReportFactsOptions { export interface BuildGroupReportFactsOptions {
onProgress?: (progress: ReportPreparationProgress) => void onProgress?: (progress: ReportPreparationProgress) => void
visionModel?: ReportModelChoice
} }
function friendlyImageNotice(warnings: string[]): string { function friendlyImageNotice(warnings: string[]): string {
@@ -422,6 +424,8 @@ const buildMediaSection = async (
sender: candidate.sender, sender: candidate.sender,
sentAt: candidate.sentAt, sentAt: candidate.sentAt,
sessionId: candidate.sessionId, sessionId: candidate.sessionId,
providerId: options.visionModel?.providerId,
modelId: options.visionModel?.model,
force: false force: false
}) })
if (!analyzeResp.success || !analyzeResp.insight) { if (!analyzeResp.success || !analyzeResp.insight) {
+11
View File
@@ -74,6 +74,17 @@ export interface AIRuntimeModelConfig {
timeoutMs?: number timeoutMs?: number
} }
/** 日报工作区内可选择的模型,不会修改全局默认 Provider。 */
export interface ReportModelChoice extends AIRuntimeModelConfig {
providerId: string
configured: true
}
export interface AIVisionRuntimeConfig extends AIRuntimeModelConfig {
/** 图片理解模型独立于文字总结模型,由已验证的 vision/ocr capability 自动选择。 */
source: 'default-model' | 'vision-capability' | 'unavailable'
}
export interface AiSearchProviderStatus { export interface AiSearchProviderStatus {
configured: boolean configured: boolean
requiresConsent: boolean requiresConsent: boolean
+19 -2
View File
@@ -12,6 +12,21 @@ export type ImageCategory =
export type ImageImportance = 'low' | 'medium' | 'high' export type ImageImportance = 'low' | 'medium' | 'high'
/** 日报图片理解结果最多复用 10 分钟;旧记录保留在磁盘,仅不再作为缓存命中。 */
export const IMAGE_INSIGHT_CACHE_TTL_MS = 10 * 60 * 1000
export const isFreshImageInsight = (
insight: Pick<ImageInsight, 'updatedAt'> | null | undefined,
now = Date.now()
): boolean =>
Boolean(
insight &&
Number.isFinite(insight.updatedAt) &&
insight.updatedAt > 0 &&
now >= insight.updatedAt &&
now - insight.updatedAt < IMAGE_INSIGHT_CACHE_TTL_MS
)
/** /**
* AI ( image-insights.json) * AI ( image-insights.json)
* *
@@ -57,6 +72,9 @@ export interface ImageAnalysisRequest {
sender: string sender: string
sentAt: number sentAt: number
sessionId: string sessionId: string
/** 日报局部选择的图片理解模型;未传时仍使用自动视觉路由。 */
providerId?: string
modelId?: string
/** 强制重新分析(忽略缓存) */ /** 强制重新分析(忽略缓存) */
force?: boolean force?: boolean
} }
@@ -118,8 +136,7 @@ export interface ImageCandidateQuery {
export const isHotImageCandidate = (input: { export const isHotImageCandidate = (input: {
responseCount: number responseCount: number
interactionCount: number interactionCount: number
}): boolean => }): boolean => input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
export const calculateImageHeatScore = (input: { export const calculateImageHeatScore = (input: {
responseCount: number responseCount: number
+6
View File
@@ -27,6 +27,10 @@ export interface GeneratedReportRecord {
height: number height: number
} }
duration?: number duration?: number
/** 文字总结模型;modelName 保留为旧记录兼容字段。 */
textModelName?: string
/** 图片理解模型。 */
imageModelName?: string
modelName?: string modelName?: string
tokenUsage?: { tokenUsage?: {
input?: number input?: number
@@ -62,6 +66,8 @@ export interface SaveGeneratedReportRequest {
htmlPath?: string htmlPath?: string
pngPath?: string pngPath?: string
duration?: number duration?: number
textModelName?: string
imageModelName?: string
modelName?: string modelName?: string
tokenUsage?: { tokenUsage?: {
input?: number input?: number
+28
View File
@@ -0,0 +1,28 @@
export interface WechatShareServiceConfig {
serviceUrl: string
uploadToken: string
}
export interface WechatShareServiceConfigResult {
success: boolean
configured?: boolean
serviceUrl?: string
error?: string
}
export interface PublishWechatShareCardRequest {
pngPath: string
title: string
description: string
expiresInDays?: number
}
export interface PublishWechatShareCardResult {
success: boolean
cardId?: string
shareUrl?: string
viewUrl?: string
qrCodeDataUrl?: string
expiresAt?: string
error?: string
}
+168
View File
@@ -4,6 +4,9 @@ import { ReportGroupMemberSelector } from '../../src/renderer/src/components/rep
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel' import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector' import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer' import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel'
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
import type { Contact } from '../../src/shared/types' import type { Contact } from '../../src/shared/types'
import type { GeneratedReportRecord } from '../../src/shared/report-history' import type { GeneratedReportRecord } from '../../src/shared/report-history'
@@ -211,6 +214,61 @@ describe('daily report controls', () => {
expect(screen.getByText(/从第三步继续/)).toBeVisible() expect(screen.getByText(/从第三步继续/)).toBeVisible()
}) })
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', () => {
const onTextModelChange = vi.fn()
const onVisionModelChange = vi.fn()
const textModels = [
{
providerId: 'deepseek',
providerName: 'DeepSeek',
model: 'deepseek-chat',
modelName: 'DeepSeek Chat',
configured: true as const,
status: 'connected' as const
},
{
providerId: 'openai',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'GPT-5.6 Sol',
configured: true as const,
status: 'connected' as const
}
]
const visionModels = [
{
providerId: 'sol-provider',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'GPT-5.6 Sol',
configured: true as const,
status: 'connected' as const
}
]
render(
<ModelSummary
config={textModels[0]}
visionConfig={visionModels[0]}
textModels={textModels}
visionModels={visionModels}
onTextModelChange={onTextModelChange}
onVisionModelChange={onVisionModelChange}
onOpenSettings={vi.fn()}
/>
)
const textSelect = screen.getByRole('combobox', { name: '文字总结模型' })
const visionSelect = screen.getByRole('combobox', { name: '图片理解模型' })
expect(textSelect).toHaveValue('deepseek::deepseek-chat')
expect(visionSelect).toHaveValue('sol-provider::gpt-5.6-sol')
expect(screen.getAllByRole('option', { name: 'OpenAI · GPT-5.6 Sol' })).toHaveLength(2)
fireEvent.change(textSelect, { target: { value: 'openai::gpt-5.6-sol' } })
fireEvent.change(visionSelect, { target: { value: 'sol-provider::gpt-5.6-sol' } })
expect(onTextModelChange).toHaveBeenCalledWith(textModels[1])
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[0])
expect(screen.getByText(/图片识别缓存 10 分钟/)).toBeVisible()
})
it('zooms relative to a full-image fit constrained by viewport width and height', () => { it('zooms relative to a full-image fit constrained by viewport width and height', () => {
const originalResizeObserver = globalThis.ResizeObserver const originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver = class { globalThis.ResizeObserver = class {
@@ -272,6 +330,116 @@ describe('daily report controls', () => {
globalThis.ResizeObserver = originalResizeObserver globalThis.ResizeObserver = originalResizeObserver
}) })
it('keeps zoom working when a newly saved report replaces the initial result', () => {
const originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver = class {
observe(): void {
return undefined
}
disconnect(): void {
return undefined
}
unobserve(): void {
return undefined
}
}
const baseReport: GeneratedReportRecord = {
id: 'temporary-result',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-13T10:00:00.000Z',
reportDate: '2026-08-13',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture'
}
const props = {
hasReports: true,
onBackToConfigure: vi.fn(),
onRegenerate: vi.fn(),
onCopyImage: vi.fn(async () => ({ success: true })),
onReveal: vi.fn(async () => ({ success: true })),
onSwitchTemplate: vi.fn(async () => ({ success: true }))
}
const { rerender } = render(<ReportViewer report={baseReport} {...props} />)
let image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
configurable: true,
value: 544
})
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
configurable: true,
value: 1044
})
fireEvent.load(image)
expect(image.style.width).toBe('500px')
rerender(<ReportViewer report={{ ...baseReport, id: 'saved-result' }} {...props} />)
image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
configurable: true,
value: 544
})
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
configurable: true,
value: 1044
})
fireEvent.load(image)
fireEvent.click(screen.getByRole('button', { name: '放大' }))
expect(image.style.width).toBe('625px')
globalThis.ResizeObserver = originalResizeObserver
})
it('keeps secondary report actions inside More and labels both AI model roles', () => {
render(
<>
<ReportToolbar
canCopyImage
canReveal
canShare
canSwitchTemplate
currentTemplateId="v1"
isSwitchingTemplate={false}
onSwitchTemplate={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn()}
onReveal={vi.fn()}
onShare={vi.fn()}
/>
<ReportInfoPanel
report={{
id: 'model-info',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-13T10:00:00.000Z',
reportDate: '2026-08-13',
htmlStatus: 'ready',
pngStatus: 'ready',
textModelName: 'deepseek-chat',
imageModelName: 'gpt-5.6-sol'
}}
onReveal={vi.fn(async () => ({ success: true }))}
/>
</>
)
expect(screen.queryByRole('button', { name: '生成微信卡片' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '更多' }))
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
expect(screen.getByText('文字模型')).toBeVisible()
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
expect(screen.getByText('图片模型')).toBeVisible()
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
})
it('switches templates from the top toolbar using the saved report snapshot', async () => { it('switches templates from the top toolbar using the saved report snapshot', async () => {
const onSwitchTemplate = vi.fn(async () => ({ success: true })) const onSwitchTemplate = vi.fn(async () => ({ success: true }))
const report: GeneratedReportRecord = { const report: GeneratedReportRecord = {
+22 -1
View File
@@ -273,6 +273,12 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
await fixture.page.getByRole('button', { name: '日报' }).click() await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click() await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible() await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
const textModel = fixture.page.getByRole('combobox', { name: '文字总结模型' })
const visionModel = fixture.page.getByRole('combobox', { name: '图片理解模型' })
await expect(textModel).toHaveValue('fixture-provider::fixture-model')
await expect(visionModel).toHaveValue('fixture-provider::fixture-vision-model')
await expect(textModel.locator('option')).toHaveCount(2)
await expect(visionModel.locator('option')).toHaveCount(1)
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await fixture.page.getByRole('button', { name: '近 7 天' }).click() await fixture.page.getByRole('button', { name: '近 7 天' }).click()
const generate = fixture.page.getByRole('button', { name: '开始生成日报' }) const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
@@ -281,6 +287,20 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({ await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({
timeout: 15_000 timeout: 15_000
}) })
await expect(fixture.page.getByText('文字模型')).toBeVisible()
await expect(fixture.page.getByText('固定响应模型')).toBeVisible()
await expect(fixture.page.getByText('图片模型')).toBeVisible()
await expect(fixture.page.getByText('固定图片识别模型')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toHaveCount(0)
await fixture.page.getByRole('button', { name: '更多' }).click()
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
await fixture.page.setViewportSize({ width: 1024, height: 760 })
const reportTitle = fixture.page.getByRole('heading', { name: '产品测试群 群聊日报' })
await expect(reportTitle).toBeVisible()
expect((await reportTitle.boundingBox())?.width || 0).toBeGreaterThan(170)
await expect(fixture.page.getByRole('button', { name: '放大' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '放大' }).click()
const exported = await fixture.page.evaluate(async () => const exported = await fixture.page.evaluate(async () =>
window.api.exportGroupReport({ window.api.exportGroupReport({
@@ -308,7 +328,8 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
await fixture.page.getByRole('button', { name: '近 7 天' }).click() await fixture.page.getByRole('button', { name: '近 7 天' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click() await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible() await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled() await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
await expect(fixture.page.getByText(/从第三步继续/)).toBeVisible()
await fixture.page.getByRole('button', { name: '档案' }).click() await fixture.page.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible() await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
await expect( await expect(
+36 -1
View File
@@ -411,9 +411,44 @@ handle('ai:getRuntimeConfig', () => ({
status: 'connected', status: 'connected',
timeoutMs: 5000 timeoutMs: 5000
})) }))
handle('ai:getVisionRuntimeConfig', () => ({
providerId: 'fixture-vision-provider',
providerName: '本地图片假服务',
model: 'fixture-vision-model',
modelName: '固定图片识别模型',
configured: true,
status: 'connected',
timeoutMs: 5000,
source: 'vision-capability'
}))
handle('ai:listProviders', () => ({ handle('ai:listProviders', () => ({
success: true, success: true,
providers: [], providers: [
{
id: 'fixture-provider',
name: '本地假服务',
type: 'openai-compatible',
baseUrl: 'http://127.0.0.1:1/v1',
auth: { type: 'none' },
models: [
{
id: 'fixture-model',
name: '固定响应模型',
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
},
{
id: 'fixture-vision-model',
name: '固定图片识别模型',
capabilities: { chat: true, vision: true, ocr: true, longContext: true }
}
],
defaultModel: 'fixture-model',
advanced: { timeoutMs: 5000, extraHeaders: {} },
hasApiKey: true,
isDefault: true,
status: 'connected'
}
],
defaultProviderId: 'fixture-provider' defaultProviderId: 'fixture-provider'
})) }))
handle('ai:migrateLegacy', () => ({ success: true, providers: [] })) handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
@@ -0,0 +1,69 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { AIProviderConfig } from '../../src/shared/ai-provider'
const root = mkdtempSync(join(tmpdir(), 'tracememo-ai-vision-routing-'))
vi.mock('electron', () => ({
app: { getPath: () => root },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString('utf8')
}
}))
import { AIProviderService } from '../../src/main/services/ai-provider-service'
const provider = (id: string, modelId: string, vision: boolean): AIProviderConfig => ({
id,
name: id === 'deepseek' ? 'DeepSeek' : 'OpenAI',
type: 'openai-compatible',
baseUrl: `https://${id}.example.test/v1`,
auth: { type: 'none' },
models: [
{
id: modelId,
name: modelId,
capabilities: { chat: true, vision, ocr: vision, longContext: true }
}
],
defaultModel: modelId,
advanced: { timeoutMs: 120_000, extraHeaders: {} }
})
describe('AI provider vision routing', () => {
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('keeps DeepSeek as the text model while routing images to a verified vision model', () => {
const service = new AIProviderService()
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
expect(service.save(provider('sol-provider', 'gpt-5.6-sol', true)).success).toBe(true)
expect(service.setDefault('deepseek').success).toBe(true)
expect(service.getRuntimeConfig()).toMatchObject({
providerId: 'deepseek',
model: 'deepseek-chat'
})
expect(service.getVisionRuntimeConfig()).toMatchObject({
providerId: 'sol-provider',
model: 'gpt-5.6-sol',
configured: true,
source: 'vision-capability'
})
})
it('reports unavailable when no configured model has vision capability', () => {
const service = new AIProviderService()
for (const item of service.list().providers) service.delete(item.id)
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
expect(service.getVisionRuntimeConfig()).toMatchObject({
configured: false,
model: '',
source: 'unavailable'
})
})
})
+16 -1
View File
@@ -210,9 +210,24 @@ describe('group report parsing', () => {
}) })
const input = await buildGroupReportInput(messages, null, true, 'full', { const input = await buildGroupReportInput(messages, null, true, 'full', {
onProgress: progress onProgress: progress,
visionModel: {
providerId: 'selected-vision-provider',
providerName: '视觉服务',
model: 'selected-vision-model',
modelName: '视觉模型',
configured: true,
status: 'connected'
}
}) })
expect(window.api.imageAnalyze).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'selected-vision-provider',
modelId: 'selected-vision-model'
})
)
expect(input.prompt).toContain('AI 图片识别摘要:') expect(input.prompt).toContain('AI 图片识别摘要:')
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。') expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
expect(input.prompt).toContain('OCR: 项目 状态 负责人') expect(input.prompt).toContain('OCR: 项目 状态 负责人')
+213 -4
View File
@@ -1,11 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
IMAGE_INSIGHT_CACHE_TTL_MS,
isFreshImageInsight,
type ImageInsight
} from '../../src/shared/image-insight'
const { getByHash } = vi.hoisted(() => ({ getByHash: vi.fn() })) const { getByHash, upsert } = vi.hoisted(() => ({
getByHash: vi.fn(),
upsert: vi.fn()
}))
vi.mock('../../src/main/db/image-insights-store', () => ({ vi.mock('../../src/main/db/image-insights-store', () => ({
imageInsightsStore: { imageInsightsStore: {
getByHash, getByHash,
upsert: vi.fn(), upsert,
listBySession: vi.fn() listBySession: vi.fn()
} }
})) }))
@@ -19,7 +27,11 @@ const query = {
limit: 3 limit: 3
} }
const input = (id: string, responseCount: number, interactionCount: number): { const input = (
id: string,
responseCount: number,
interactionCount: number
): {
messageId: string messageId: string
md5: string md5: string
sessionId: string sessionId: string
@@ -41,6 +53,7 @@ describe('ImageInsightService hot image selection', () => {
beforeEach(() => { beforeEach(() => {
getByHash.mockReset() getByHash.mockReset()
getByHash.mockReturnValue(null) getByHash.mockReturnValue(null)
upsert.mockReset()
}) })
it('returns fewer than three images when only two pass the hot threshold', async () => { it('returns fewer than three images when only two pass the hot threshold', async () => {
@@ -78,11 +91,207 @@ describe('ImageInsightService hot image selection', () => {
}) })
it('keeps a cached insight attached to an eligible candidate', async () => { it('keeps a cached insight attached to an eligible candidate', async () => {
const cached = { imageHash: 'a'.repeat(32), description: '缓存识别结果' } const cached = {
imageHash: 'a'.repeat(32),
description: '缓存识别结果',
updatedAt: Date.now()
}
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null)) getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)]) const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached }) expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
}) })
it('does not attach a cached insight once its 10-minute TTL has elapsed', async () => {
getByHash.mockReturnValue({
imageHash: 'a'.repeat(32),
description: '过期识别结果',
updatedAt: Date.now() - IMAGE_INSIGHT_CACHE_TTL_MS
})
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
expect(result[0]).not.toHaveProperty('insight')
})
})
describe('ImageInsightService cache TTL and vision routing', () => {
const now = new Date('2026-08-12T10:00:00.000Z').getTime()
const cachedInsight = (updatedAt: number): ImageInsight => ({
id: 'cached-insight',
messageId: 'image-1',
imageHash: 'a'.repeat(32),
description: '缓存图片描述',
tags: ['缓存'],
category: 'screenshot',
importance: 'medium',
provider: 'vision-provider',
model: 'vision-model',
createdAt: updatedAt,
updatedAt,
sender: '成员一',
sentAt: now,
sessionId: 'group@chatroom'
})
beforeEach(() => {
vi.useRealTimers()
getByHash.mockReset()
getByHash.mockReturnValue(null)
upsert.mockReset()
})
it('treats only results strictly newer than 10 minutes as fresh', () => {
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1), now)).toBe(true)
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS), now)).toBe(false)
expect(isFreshImageInsight(cachedInsight(0), now)).toBe(false)
})
it('uses the independent vision runtime after an expired cache entry', async () => {
vi.useFakeTimers()
vi.setSystemTime(now)
getByHash.mockReturnValue(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS))
const analyzeImage = vi.fn(async () => ({
success: true,
data: JSON.stringify({
description: '重新识别后的图片描述',
ocrText: '新的 OCR',
tags: ['更新', '截图'],
category: 'screenshot',
importance: 'high'
})
}))
const getVisionRuntimeConfig = vi.fn(() => ({
providerId: 'sol-provider',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'gpt-5.6-sol',
configured: true
}))
imageInsightService.bind({
providerService: {
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
getVisionRuntimeConfig,
analyzeImage
},
decryptService: {
findImageFile: () => null,
decryptImageToBase64: () => null
}
})
const result = await imageInsightService.analyze({
imageHash: 'a'.repeat(32),
imageDataUrl: 'data:image/png;base64,fixture',
messageId: 'image-1',
sender: '成员一',
sentAt: now,
sessionId: 'group@chatroom'
})
expect(result).toMatchObject({ success: true, fromCache: false })
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
providerId: 'sol-provider',
modelId: 'gpt-5.6-sol'
})
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
description: '重新识别后的图片描述',
provider: 'sol-provider',
model: 'gpt-5.6-sol',
updatedAt: now
})
)
})
it('uses the report-selected vision model on a cache miss', async () => {
vi.useFakeTimers()
vi.setSystemTime(now)
const analyzeImage = vi.fn(async () => ({
success: true,
data: JSON.stringify({
description: '指定模型识别结果',
tags: ['指定'],
category: 'screenshot',
importance: 'medium'
})
}))
const getVisionRuntimeConfig = vi.fn(() => ({
providerId: 'automatic-provider',
providerName: '自动模型',
model: 'automatic-vision',
modelName: '自动视觉模型',
configured: true
}))
imageInsightService.bind({
providerService: {
list: () => ({ providers: [], defaultProviderId: 'automatic-provider' }),
getVisionRuntimeConfig,
analyzeImage
},
decryptService: {
findImageFile: () => null,
decryptImageToBase64: () => null
}
})
const result = await imageInsightService.analyze({
imageHash: 'b'.repeat(32),
imageDataUrl: 'data:image/png;base64,fixture',
messageId: 'image-selected',
sender: '成员二',
sentAt: now,
sessionId: 'group@chatroom',
providerId: 'selected-provider',
modelId: 'selected-vision'
})
expect(result).toMatchObject({ success: true, fromCache: false })
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
providerId: 'selected-provider',
modelId: 'selected-vision'
})
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'selected-provider', model: 'selected-vision' })
)
})
it('returns a fresh cached result without calling the vision model', async () => {
vi.useFakeTimers()
vi.setSystemTime(now)
const cached = cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1)
getByHash.mockReturnValue(cached)
const analyzeImage = vi.fn()
imageInsightService.bind({
providerService: {
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
getVisionRuntimeConfig: () => ({
providerId: 'sol-provider',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'gpt-5.6-sol',
configured: true
}),
analyzeImage
},
decryptService: {
findImageFile: () => null,
decryptImageToBase64: () => null
}
})
const result = await imageInsightService.analyze({
imageHash: cached.imageHash,
imageDataUrl: 'data:image/png;base64,fixture',
messageId: cached.messageId,
sender: cached.sender,
sentAt: cached.sentAt,
sessionId: cached.sessionId
})
expect(result).toEqual({ success: true, insight: cached, fromCache: true })
expect(analyzeImage).not.toHaveBeenCalled()
expect(upsert).not.toHaveBeenCalled()
})
}) })