mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 新增实验性微信卡片分享与自动部署能力
补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权 增加自动部署 Skill 和配置引导文档 优化报告工具栏、微信卡片弹窗及窄屏响应式布局 补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
This commit is contained in:
@@ -26,3 +26,11 @@ VITE_IMAGE_AES_KEY=
|
||||
# Electron E2E test window close delay in milliseconds.
|
||||
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
|
||||
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=
|
||||
|
||||
@@ -12,6 +12,8 @@ test-results/
|
||||
resources/connectors/wechat/
|
||||
.omc
|
||||
.codex/
|
||||
services/share-card-worker/.wrangler/
|
||||
services/share-card-worker/wrangler.local.jsonc
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
|
||||
@@ -12,6 +12,8 @@ TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块
|
||||
|
||||
- [建立本地知识库](./user-guide/knowledge.md)
|
||||
- [生成群聊日报和总结](./user-guide/report.md)
|
||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
|
||||
- [语音转文字](./user-guide/voice.md)
|
||||
- [导出聊天档案](./user-guide/export.md)
|
||||
- [防撤回](./user-guide/recall-protection.md)
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
# 实验性功能:自托管微信分享卡片
|
||||
|
||||

|
||||
|
||||
> **实验性功能**
|
||||
> 该能力需要用户自行准备 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
@@ -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
@@ -78,6 +78,7 @@
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sherpa-onnx-node": "1.13.3",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
@@ -95,6 +96,7 @@
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -113,7 +115,8 @@
|
||||
"sass": "^1.102.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.10",
|
||||
"wrangler": "^4.28.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
|
||||
Generated
+856
-14
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
|
||||
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}×tamp=${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')">&`),
|
||||
'<img src=x onerror="alert('x')">&'
|
||||
)
|
||||
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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,12 @@ import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
|
||||
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
|
||||
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
|
||||
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.
|
||||
// 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 keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||
const wechatShareCardService = new WechatShareCardService(wechatShareConfigStore)
|
||||
let tray: Tray | null = null
|
||||
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
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(
|
||||
'db:getVoiceData',
|
||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||
|
||||
@@ -81,6 +81,7 @@ const normalizeRecord = async (
|
||||
const pngStatus = await fileStatus(record.pngPath)
|
||||
return {
|
||||
...record,
|
||||
textModelName: record.textModelName || record.modelName,
|
||||
jsonPath,
|
||||
htmlStatus,
|
||||
pngStatus,
|
||||
@@ -181,6 +182,8 @@ export async function saveGeneratedReport(
|
||||
pngStatus: savedPngPath ? 'ready' : 'missing',
|
||||
imageSize: await readPngSize(savedPngPath),
|
||||
duration: request.duration,
|
||||
textModelName: request.textModelName || request.modelName,
|
||||
imageModelName: request.imageModelName,
|
||||
modelName: request.modelName,
|
||||
tokenUsage: request.tokenUsage,
|
||||
fileSize: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AIProviderSummary,
|
||||
AiSearchProviderStatus,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionRuntimeConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
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 {
|
||||
const result = this.list()
|
||||
const provider =
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
||||
// 2. 同图(imageHash)走缓存,绝不重复调 AI
|
||||
// 2. 同图(imageHash)在 10 分钟内走缓存,过期后重新调 AI
|
||||
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
|
||||
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
} from '../../shared/image-insight'
|
||||
import {
|
||||
calculateImageHeatScore,
|
||||
isFreshImageInsight,
|
||||
isHotImageCandidate
|
||||
} from '../../shared/image-insight'
|
||||
|
||||
@@ -46,6 +47,13 @@ export interface ImageCandidateInput {
|
||||
|
||||
interface ProviderServiceLike {
|
||||
list(): ProviderSummaryLike
|
||||
getVisionRuntimeConfig(): {
|
||||
providerId?: string
|
||||
providerName: string
|
||||
model: string
|
||||
modelName: string
|
||||
configured: boolean
|
||||
}
|
||||
analyzeImage(
|
||||
messages: Array<{
|
||||
role: string
|
||||
@@ -81,7 +89,7 @@ interface ProviderSummaryLike {
|
||||
class ImageInsightService {
|
||||
private providerService: ProviderServiceLike | null = null
|
||||
private decryptService: DecryptServiceLike | null = null
|
||||
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
|
||||
/** 最近一次实际使用的视觉 provider/model,仅用于写入分析元数据 */
|
||||
private runtimeProviderId: string | undefined = undefined
|
||||
private runtimeModelId: string | undefined = undefined
|
||||
|
||||
@@ -97,13 +105,11 @@ class ImageInsightService {
|
||||
this.runtimeProviderId,
|
||||
this.runtimeModelId
|
||||
)
|
||||
// 读取默认 provider/model(后续 analyze 时使用)
|
||||
// 读取当前视觉 provider/model(后续 analyze 时仍会刷新,避免配置变化后继续用旧模型)
|
||||
try {
|
||||
const list = deps.providerService.list()
|
||||
const provider =
|
||||
list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0]
|
||||
this.runtimeProviderId = provider?.id
|
||||
this.runtimeModelId = provider?.defaultModel
|
||||
const runtime = deps.providerService.getVisionRuntimeConfig()
|
||||
this.runtimeProviderId = runtime.providerId
|
||||
this.runtimeModelId = runtime.model || undefined
|
||||
console.log(
|
||||
'[ImageInsightService] bind loaded default provider=%s model=%s',
|
||||
this.runtimeProviderId,
|
||||
@@ -145,7 +151,7 @@ class ImageInsightService {
|
||||
|
||||
/**
|
||||
* 主入口:分析一张图片。
|
||||
* 1. 通过 imageHash 查缓存,命中即返回
|
||||
* 1. 通过 imageHash 查 10 分钟缓存,新鲜则返回
|
||||
* 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
|
||||
* 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
|
||||
*/
|
||||
@@ -153,8 +159,15 @@ class ImageInsightService {
|
||||
try {
|
||||
if (!request.force) {
|
||||
const cached = imageInsightsStore.getByHash(request.imageHash)
|
||||
if (isFreshImageInsight(cached)) {
|
||||
return { success: true, insight: cached || undefined, fromCache: true }
|
||||
}
|
||||
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 provider =
|
||||
list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0]
|
||||
this.runtimeProviderId = provider?.id
|
||||
this.runtimeModelId = provider?.defaultModel
|
||||
const runtime =
|
||||
request.providerId && request.modelId
|
||||
? {
|
||||
providerId: request.providerId,
|
||||
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, {
|
||||
providerId: this.runtimeProviderId,
|
||||
modelId: this.runtimeModelId
|
||||
@@ -273,7 +299,7 @@ class ImageInsightService {
|
||||
heatScore
|
||||
}
|
||||
const cached = imageInsightsStore.getByHash(hash)
|
||||
if (cached) candidate.insight = cached
|
||||
if (isFreshImageInsight(cached) && cached) candidate.insight = cached
|
||||
candidates.push(candidate)
|
||||
}
|
||||
candidates.sort((a, b) => b.heatScore - a.heatScore)
|
||||
@@ -286,7 +312,6 @@ class ImageInsightService {
|
||||
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||
return imageInsightsStore.listBySession(sessionId, limit)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const imageInsightService = new ImageInsightService()
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -79,6 +79,12 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
PublishWechatShareCardResult,
|
||||
WechatShareServiceConfig,
|
||||
WechatShareServiceConfigResult
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
@@ -327,6 +333,13 @@ declare global {
|
||||
) => Promise<UpdateGeneratedReportTemplateResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
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>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
readDatabaseKeyClipboard: () => Promise<{
|
||||
|
||||
@@ -54,6 +54,10 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
WechatShareServiceConfig
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -224,6 +228,11 @@ const api = {
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
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),
|
||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||
|
||||
+132
-8
@@ -6,7 +6,11 @@ import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
||||
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
||||
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
|
||||
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 { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
||||
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
||||
@@ -73,6 +77,51 @@ const INITIAL_MESSAGE_COUNT = 20
|
||||
const MESSAGE_PAGE_SIZE = 100
|
||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||
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 => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
@@ -209,6 +258,16 @@ function App(): React.ReactElement {
|
||||
configured: false,
|
||||
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 [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||
@@ -336,7 +395,25 @@ function App(): React.ReactElement {
|
||||
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) {
|
||||
console.warn('[AI Provider] 配置加载失败:', error)
|
||||
}
|
||||
@@ -349,7 +426,8 @@ function App(): React.ReactElement {
|
||||
sourceContact: reportSourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig: aiModelConfig
|
||||
modelConfig: reportTextModelConfig,
|
||||
visionModelConfig: aiVisionModelConfig
|
||||
})
|
||||
const lastCapturedReportKeyRef = React.useRef('')
|
||||
|
||||
@@ -1496,7 +1574,10 @@ function App(): React.ReactElement {
|
||||
htmlPath: reportGeneration.reportPaths?.htmlPath,
|
||||
pngPath: reportGeneration.reportPaths?.pngPath,
|
||||
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,
|
||||
generationLogs: reportGeneration.generationMetadata.generationLogs,
|
||||
reportSnapshot,
|
||||
@@ -1521,7 +1602,10 @@ function App(): React.ReactElement {
|
||||
|
||||
void saveReport()
|
||||
}, [
|
||||
aiModelConfig.model,
|
||||
aiVisionModelConfig?.model,
|
||||
aiVisionModelConfig?.modelName,
|
||||
reportTextModelConfig.model,
|
||||
reportTextModelConfig.modelName,
|
||||
reportGeneration.generatedImage,
|
||||
reportGeneration.generationMetadata,
|
||||
reportGeneration.phase,
|
||||
@@ -1689,7 +1773,10 @@ function App(): React.ReactElement {
|
||||
sourceContact={reportSourceContact}
|
||||
summaryDateRange={summaryDateRange}
|
||||
summaryMessageTypes={summaryMessageTypes}
|
||||
modelConfig={aiModelConfig}
|
||||
modelConfig={reportTextModelConfig}
|
||||
visionModelConfig={aiVisionModelConfig}
|
||||
textModelOptions={reportTextModelOptions}
|
||||
visionModelOptions={reportVisionModelOptions}
|
||||
rangeMessageCount={reportGeneration.rangeMessages.length}
|
||||
reportMessageCount={reportGeneration.reportMessages.length}
|
||||
messageTypeCounts={reportGeneration.messageTypeCounts}
|
||||
@@ -1702,6 +1789,14 @@ function App(): React.ReactElement {
|
||||
onSummaryDateRangeChange={setSummaryDateRange}
|
||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
||||
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={() => {
|
||||
reportGeneration.resetGenerationStatus()
|
||||
void reportGeneration.generate()
|
||||
@@ -1726,7 +1821,7 @@ function App(): React.ReactElement {
|
||||
preparationProgress={reportGeneration.preparationProgress}
|
||||
imageInsightSummary={reportGeneration.imageInsightSummary}
|
||||
canRetryModelStep={reportGeneration.canRetryModelStep}
|
||||
currentModel={aiModelConfig}
|
||||
currentModel={reportTextModelConfig}
|
||||
onRetry={(model) => void reportGeneration.retry(model)}
|
||||
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
||||
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
||||
@@ -1765,7 +1860,36 @@ function App(): React.ReactElement {
|
||||
onContactsChange={setContacts}
|
||||
onFilteredContactsChange={setFilteredContacts}
|
||||
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}
|
||||
onOpenSettings={openSettings}
|
||||
onAppearanceChange={handleAppearanceChange}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import type { ReportModelChoice } from '../../../../shared/ai-provider'
|
||||
import {
|
||||
AiModelConfig,
|
||||
RangeMessageState,
|
||||
@@ -22,6 +23,9 @@ interface AiReportWorkspaceProps {
|
||||
summaryDateRange: SummaryDateRange
|
||||
summaryMessageTypes: SummaryMessageType[]
|
||||
modelConfig: AiModelConfig
|
||||
visionModelConfig?: ReportModelChoice
|
||||
textModelOptions: ReportModelChoice[]
|
||||
visionModelOptions: ReportModelChoice[]
|
||||
rangeMessageCount: number
|
||||
reportMessageCount: number
|
||||
messageTypeCounts: Record<SummaryMessageType, number>
|
||||
@@ -34,6 +38,8 @@ interface AiReportWorkspaceProps {
|
||||
onSummaryDateRangeChange: (value: SummaryDateRange) => void
|
||||
onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void
|
||||
onOpenModelSettings: () => void
|
||||
onTextModelChange: (model: ReportModelChoice) => void
|
||||
onVisionModelChange: (model: ReportModelChoice) => void
|
||||
onGenerate: () => void
|
||||
onCloseResult: () => void
|
||||
onCopyImage: () => Promise<{ success: boolean; error?: string }>
|
||||
@@ -69,6 +75,9 @@ export function AiReportWorkspace({
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig,
|
||||
visionModelConfig,
|
||||
textModelOptions,
|
||||
visionModelOptions,
|
||||
rangeMessageCount,
|
||||
reportMessageCount,
|
||||
messageTypeCounts,
|
||||
@@ -81,6 +90,8 @@ export function AiReportWorkspace({
|
||||
onSummaryDateRangeChange,
|
||||
onSummaryMessageTypesChange,
|
||||
onOpenModelSettings,
|
||||
onTextModelChange,
|
||||
onVisionModelChange,
|
||||
onGenerate,
|
||||
onCloseResult,
|
||||
onCopyImage,
|
||||
@@ -171,7 +182,16 @@ export function AiReportWorkspace({
|
||||
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">
|
||||
<div>
|
||||
<h3>日报生成超时</h3>
|
||||
|
||||
@@ -1,24 +1,87 @@
|
||||
import React from 'react'
|
||||
import type { ReportModelChoice } from '../../../../shared/ai-provider'
|
||||
import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
|
||||
|
||||
interface ModelSummaryProps {
|
||||
config: AiModelConfig
|
||||
visionConfig?: ReportModelChoice
|
||||
textModels: ReportModelChoice[]
|
||||
visionModels: ReportModelChoice[]
|
||||
disabled?: boolean
|
||||
onTextModelChange: (model: ReportModelChoice) => void
|
||||
onVisionModelChange: (model: ReportModelChoice) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement {
|
||||
const statusText = config.configured ? '配置正常' : '尚未配置'
|
||||
const modelKey = (model: { providerId?: string; model: string } | undefined): string =>
|
||||
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 (
|
||||
<section className="report-config-section">
|
||||
<div className="report-model-summary">
|
||||
<div>
|
||||
<div className="report-model-summary-content">
|
||||
<h3>模型配置</h3>
|
||||
<p>
|
||||
{config.modelName || config.model || '未选择模型'} · {statusText}
|
||||
</p>
|
||||
<div className="report-model-selects">
|
||||
<label>
|
||||
<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>
|
||||
<small>图片识别缓存 10 分钟;识图完成后仍由文字总结模型生成日报。</small>
|
||||
</div>
|
||||
<button type="button" onClick={onOpenSettings}>
|
||||
<button type="button" onClick={onOpenSettings} disabled={disabled}>
|
||||
更改模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +108,12 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea
|
||||
<b>{formatDuration(report.duration)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>模型</span>
|
||||
<b>{modelLabel(report.modelName)}</b>
|
||||
<span>文字模型</span>
|
||||
<b>{modelLabel(report.textModelName || report.modelName)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>图片模型</span>
|
||||
<b>{modelLabel(report.imageModelName)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>Token 来源</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
interface ReportToolbarProps {
|
||||
canCopyImage: boolean
|
||||
canReveal: boolean
|
||||
canShare: boolean
|
||||
canSwitchTemplate: boolean
|
||||
currentTemplateId?: SelectableReportTemplateId
|
||||
isSwitchingTemplate: boolean
|
||||
@@ -14,18 +15,21 @@ interface ReportToolbarProps {
|
||||
onRegenerate: () => void
|
||||
onCopyImage: () => void
|
||||
onReveal: () => void
|
||||
onShare: () => void
|
||||
}
|
||||
|
||||
export function ReportToolbar({
|
||||
canCopyImage,
|
||||
canReveal,
|
||||
canShare,
|
||||
canSwitchTemplate,
|
||||
currentTemplateId,
|
||||
isSwitchingTemplate,
|
||||
onSwitchTemplate,
|
||||
onRegenerate,
|
||||
onCopyImage,
|
||||
onReveal
|
||||
onReveal,
|
||||
onShare
|
||||
}: ReportToolbarProps): React.ReactElement {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [templateOpen, setTemplateOpen] = useState(false)
|
||||
@@ -88,6 +92,9 @@ export function ReportToolbar({
|
||||
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
|
||||
打开报告
|
||||
</button>
|
||||
<button type="button" disabled={!canShare} onClick={onShare}>
|
||||
生成微信卡片
|
||||
</button>
|
||||
<div className="report-more-menu" ref={menuRef}>
|
||||
<button type="button" onClick={() => setMoreOpen((open) => !open)}>
|
||||
更多
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReportEmptyState } from './ReportEmptyState'
|
||||
import { ReportToolbar } from './ReportToolbar'
|
||||
import { ReportZoomBar } from './ReportZoomBar'
|
||||
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||
import { WechatShareCardDialog } from './WechatShareCardDialog'
|
||||
|
||||
interface ReportViewerProps {
|
||||
report: GeneratedReportRecord | null
|
||||
@@ -49,6 +50,7 @@ export function ReportViewer({
|
||||
const [status, setStatus] = useState('')
|
||||
const [imageError, setImageError] = useState('')
|
||||
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false)
|
||||
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -57,6 +59,7 @@ export function ReportViewer({
|
||||
setStatus('')
|
||||
setImageError('')
|
||||
setIsSwitchingTemplate(false)
|
||||
setShareDialogOpen(false)
|
||||
setZoom(1)
|
||||
setFitZoom(1)
|
||||
setNaturalSize(null)
|
||||
@@ -165,6 +168,7 @@ export function ReportViewer({
|
||||
<ReportToolbar
|
||||
canCopyImage={Boolean(report.generatedImage)}
|
||||
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
||||
canShare={Boolean(report.pngPath)}
|
||||
canSwitchTemplate={Boolean(
|
||||
(report.reportSnapshot && report.reportMetadata) ||
|
||||
report.reportRenderSnapshot ||
|
||||
@@ -176,6 +180,7 @@ export function ReportViewer({
|
||||
onRegenerate={onRegenerate}
|
||||
onCopyImage={() => void handleCopy()}
|
||||
onReveal={() => void handleReveal()}
|
||||
onShare={() => setShareDialogOpen(true)}
|
||||
/>
|
||||
</header>
|
||||
{status && <div className="report-viewer-status">{status}</div>}
|
||||
@@ -233,6 +238,14 @@ export function ReportViewer({
|
||||
onFitPage={fitPage}
|
||||
onActualSize={showActualSize}
|
||||
/>
|
||||
{shareDialogOpen && report.pngPath && (
|
||||
<WechatShareCardDialog
|
||||
pngPath={report.pngPath}
|
||||
initialTitle={`${report.contactName}日报 · ${report.reportDate}`}
|
||||
initialDescription={`基于 ${report.messageCount} 条群聊消息生成的 AI 日报`}
|
||||
onClose={() => setShareDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</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'
|
||||
import type { VoiceModelStatus } from '../../../shared/voice-recognition'
|
||||
import { resolveMemberName } from '../../../shared/member-names'
|
||||
import type { ReportModelChoice } from '../../../shared/ai-provider'
|
||||
|
||||
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
|
||||
|
||||
@@ -86,6 +87,7 @@ interface UseGroupReportGenerationArgs {
|
||||
summaryDateRange: SummaryDateRange
|
||||
summaryMessageTypes: SummaryMessageType[]
|
||||
modelConfig: AiModelConfig
|
||||
visionModelConfig?: ReportModelChoice
|
||||
}
|
||||
|
||||
interface PreparedReportContext {
|
||||
@@ -260,7 +262,8 @@ export function useGroupReportGeneration({
|
||||
sourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig
|
||||
modelConfig,
|
||||
visionModelConfig
|
||||
}: UseGroupReportGenerationArgs): {
|
||||
phase: ReportGenerationPhase
|
||||
error: string
|
||||
@@ -464,7 +467,7 @@ export function useGroupReportGeneration({
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
context.logs.push(log)
|
||||
setGenerationMetadata({
|
||||
modelName: selectedModel.model,
|
||||
modelName: selectedModel.modelName || selectedModel.model,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
}
|
||||
@@ -482,7 +485,7 @@ export function useGroupReportGeneration({
|
||||
setPhase('requestingModel')
|
||||
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
|
||||
setGenerationMetadata({
|
||||
modelName: selectedModel.model,
|
||||
modelName: selectedModel.modelName || selectedModel.model,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
writeReportLog('info', '调用模型生成日报内容', {
|
||||
@@ -619,7 +622,7 @@ export function useGroupReportGeneration({
|
||||
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
|
||||
? exportFinishTime - context.startedAt
|
||||
: Date.now() - context.startedAt,
|
||||
modelName: selectedModel.model,
|
||||
modelName: selectedModel.modelName || selectedModel.model,
|
||||
tokenUsage,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
@@ -681,7 +684,10 @@ export function useGroupReportGeneration({
|
||||
const logs: ReportGenerationLog[] = []
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
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 startedAt = new Date()
|
||||
@@ -702,7 +708,10 @@ export function useGroupReportGeneration({
|
||||
setVoiceTranscriptionProgress(null)
|
||||
setPreparationProgress(null)
|
||||
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
|
||||
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [] })
|
||||
setGenerationMetadata({
|
||||
modelName: modelConfig.modelName || modelConfig.model,
|
||||
generationLogs: []
|
||||
})
|
||||
writeReportLog('info', '开始生成群聊日报', {
|
||||
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||
dateRange: summaryDateRange,
|
||||
@@ -736,7 +745,8 @@ export function useGroupReportGeneration({
|
||||
memberNamePreference
|
||||
)
|
||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
|
||||
onProgress: setPreparationProgress
|
||||
onProgress: setPreparationProgress,
|
||||
visionModel: visionModelConfig
|
||||
})
|
||||
})
|
||||
|
||||
@@ -782,7 +792,8 @@ export function useGroupReportGeneration({
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
templateId,
|
||||
transcribeSelectedVoiceMessages
|
||||
transcribeSelectedVoiceMessages,
|
||||
visionModelConfig
|
||||
])
|
||||
|
||||
const retry = useCallback(
|
||||
|
||||
@@ -467,12 +467,57 @@
|
||||
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 {
|
||||
margin: 4px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
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-result-actions button,
|
||||
.report-task-error button {
|
||||
|
||||
@@ -245,19 +245,27 @@
|
||||
flex: 0 0 auto;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 18px 22px 12px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
.report-viewer-header > div:first-child {
|
||||
flex: 1 1 220px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.report-viewer-header h1 {
|
||||
font: 700 20px/27px var(--wxex-font);
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.report-viewer-toolbar {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
@@ -592,6 +600,15 @@
|
||||
.report-viewer-header {
|
||||
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);
|
||||
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
|
||||
} from '../../../shared/image-insight'
|
||||
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
|
||||
import type { ReportModelChoice } from '../../../shared/ai-provider'
|
||||
|
||||
interface ReportImageReadResult {
|
||||
success: boolean
|
||||
@@ -93,6 +94,7 @@ export interface ReportPreparationProgress {
|
||||
|
||||
export interface BuildGroupReportFactsOptions {
|
||||
onProgress?: (progress: ReportPreparationProgress) => void
|
||||
visionModel?: ReportModelChoice
|
||||
}
|
||||
|
||||
function friendlyImageNotice(warnings: string[]): string {
|
||||
@@ -422,6 +424,8 @@ const buildMediaSection = async (
|
||||
sender: candidate.sender,
|
||||
sentAt: candidate.sentAt,
|
||||
sessionId: candidate.sessionId,
|
||||
providerId: options.visionModel?.providerId,
|
||||
modelId: options.visionModel?.model,
|
||||
force: false
|
||||
})
|
||||
if (!analyzeResp.success || !analyzeResp.insight) {
|
||||
|
||||
@@ -74,6 +74,17 @@ export interface AIRuntimeModelConfig {
|
||||
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 {
|
||||
configured: boolean
|
||||
requiresConsent: boolean
|
||||
|
||||
@@ -12,6 +12,21 @@ export type ImageCategory =
|
||||
|
||||
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)
|
||||
*
|
||||
@@ -57,6 +72,9 @@ export interface ImageAnalysisRequest {
|
||||
sender: string
|
||||
sentAt: number
|
||||
sessionId: string
|
||||
/** 日报局部选择的图片理解模型;未传时仍使用自动视觉路由。 */
|
||||
providerId?: string
|
||||
modelId?: string
|
||||
/** 强制重新分析(忽略缓存) */
|
||||
force?: boolean
|
||||
}
|
||||
@@ -118,8 +136,7 @@ export interface ImageCandidateQuery {
|
||||
export const isHotImageCandidate = (input: {
|
||||
responseCount: number
|
||||
interactionCount: number
|
||||
}): boolean =>
|
||||
input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
|
||||
}): boolean => input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
|
||||
|
||||
export const calculateImageHeatScore = (input: {
|
||||
responseCount: number
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface GeneratedReportRecord {
|
||||
height: number
|
||||
}
|
||||
duration?: number
|
||||
/** 文字总结模型;modelName 保留为旧记录兼容字段。 */
|
||||
textModelName?: string
|
||||
/** 图片理解模型。 */
|
||||
imageModelName?: string
|
||||
modelName?: string
|
||||
tokenUsage?: {
|
||||
input?: number
|
||||
@@ -62,6 +66,8 @@ export interface SaveGeneratedReportRequest {
|
||||
htmlPath?: string
|
||||
pngPath?: string
|
||||
duration?: number
|
||||
textModelName?: string
|
||||
imageModelName?: string
|
||||
modelName?: string
|
||||
tokenUsage?: {
|
||||
input?: number
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { ReportGroupMemberSelector } from '../../src/renderer/src/components/rep
|
||||
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
|
||||
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
|
||||
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 { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
|
||||
@@ -211,6 +214,61 @@ describe('daily report controls', () => {
|
||||
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', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver = class {
|
||||
@@ -272,6 +330,116 @@ describe('daily report controls', () => {
|
||||
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 () => {
|
||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||
const report: GeneratedReportRecord = {
|
||||
|
||||
+22
-1
@@ -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 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.getByRole('button', { name: '近 7 天' }).click()
|
||||
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({
|
||||
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 () =>
|
||||
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: '开始生成日报' }).click()
|
||||
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 expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
|
||||
await expect(
|
||||
|
||||
@@ -411,9 +411,44 @@ handle('ai:getRuntimeConfig', () => ({
|
||||
status: 'connected',
|
||||
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', () => ({
|
||||
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'
|
||||
}))
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -210,9 +210,24 @@ describe('group report parsing', () => {
|
||||
})
|
||||
|
||||
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('一张表格型网页截图,包含多列数据。')
|
||||
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
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', () => ({
|
||||
imageInsightsStore: {
|
||||
getByHash,
|
||||
upsert: vi.fn(),
|
||||
upsert,
|
||||
listBySession: vi.fn()
|
||||
}
|
||||
}))
|
||||
@@ -19,7 +27,11 @@ const query = {
|
||||
limit: 3
|
||||
}
|
||||
|
||||
const input = (id: string, responseCount: number, interactionCount: number): {
|
||||
const input = (
|
||||
id: string,
|
||||
responseCount: number,
|
||||
interactionCount: number
|
||||
): {
|
||||
messageId: string
|
||||
md5: string
|
||||
sessionId: string
|
||||
@@ -41,6 +53,7 @@ describe('ImageInsightService hot image selection', () => {
|
||||
beforeEach(() => {
|
||||
getByHash.mockReset()
|
||||
getByHash.mockReturnValue(null)
|
||||
upsert.mockReset()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
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))
|
||||
|
||||
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user