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.
|
# Electron E2E test window close delay in milliseconds.
|
||||||
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
|
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
|
||||||
WXE_E2E_CLOSE_DELAY_MS=2000
|
WXE_E2E_CLOSE_DELAY_MS=2000
|
||||||
|
|
||||||
|
# Experimental self-hosted WeChat share-card service
|
||||||
|
# Copy these placeholders to .env. Never commit real AppSecret or UPLOAD_TOKEN values.
|
||||||
|
WECHAT_SHARE_DOMAIN=share.example.com
|
||||||
|
WECHAT_SHARE_APP_ID=
|
||||||
|
WECHAT_SHARE_APP_SECRET=
|
||||||
|
# Leave empty to let docs/skill/setup-wechat-share-card/scripts/setup.sh generate one.
|
||||||
|
WECHAT_SHARE_UPLOAD_TOKEN=
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ test-results/
|
|||||||
resources/connectors/wechat/
|
resources/connectors/wechat/
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
|
services/share-card-worker/.wrangler/
|
||||||
|
services/share-card-worker/wrangler.local.jsonc
|
||||||
docs/design/
|
docs/design/
|
||||||
docs/ui-redesign-plan.md
|
docs/ui-redesign-plan.md
|
||||||
docs/ui-redesign-spec.md
|
docs/ui-redesign-spec.md
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块
|
|||||||
|
|
||||||
- [建立本地知识库](./user-guide/knowledge.md)
|
- [建立本地知识库](./user-guide/knowledge.md)
|
||||||
- [生成群聊日报和总结](./user-guide/report.md)
|
- [生成群聊日报和总结](./user-guide/report.md)
|
||||||
|
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||||
|
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
|
||||||
- [语音转文字](./user-guide/voice.md)
|
- [语音转文字](./user-guide/voice.md)
|
||||||
- [导出聊天档案](./user-guide/export.md)
|
- [导出聊天档案](./user-guide/export.md)
|
||||||
- [防撤回](./user-guide/recall-protection.md)
|
- [防撤回](./user-guide/recall-protection.md)
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
# 实验性功能:自托管微信分享卡片
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> **实验性功能**
|
||||||
|
> 该能力需要用户自行准备 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",
|
"jsonrepair": "^3.15.0",
|
||||||
"koffi": "^3.1.0",
|
"koffi": "^3.1.0",
|
||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sherpa-onnx-node": "1.13.3",
|
"sherpa-onnx-node": "1.13.3",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2"
|
"wechat-emojis": "^1.0.2"
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
"@types/archiver": "^8.0.0",
|
"@types/archiver": "^8.0.0",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^22.19.1",
|
"@types/node": "^22.19.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
@@ -113,7 +115,8 @@
|
|||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10",
|
||||||
|
"wrangler": "^4.28.1"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"supportedArchitectures": {
|
"supportedArchitectures": {
|
||||||
|
|||||||
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 { AiSearchPipelineService } from './services/ai-search-pipeline-service'
|
||||||
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
|
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
|
||||||
import { runFirstLaunchMigration } from './app-data-migration'
|
import { runFirstLaunchMigration } from './app-data-migration'
|
||||||
|
import { WechatShareConfigStore } from './wechat-share-config-store'
|
||||||
|
import { WechatShareCardService } from './wechat-share-card-service'
|
||||||
|
import type {
|
||||||
|
PublishWechatShareCardRequest,
|
||||||
|
WechatShareServiceConfig
|
||||||
|
} from '../shared/wechat-share-card'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
@@ -154,6 +160,8 @@ const imageKeyConfigService = new ImageKeyConfigService()
|
|||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
|
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||||
|
const wechatShareCardService = new WechatShareCardService(wechatShareConfigStore)
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||||
let recallProtectionGeneration = 0
|
let recallProtectionGeneration = 0
|
||||||
@@ -1201,6 +1209,14 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('wechat-share:getConfig', async () => wechatShareConfigStore.status())
|
||||||
|
ipcMain.handle('wechat-share:saveConfig', async (_, config: WechatShareServiceConfig) =>
|
||||||
|
wechatShareConfigStore.save(config)
|
||||||
|
)
|
||||||
|
ipcMain.handle('wechat-share:publish', async (_, request: PublishWechatShareCardRequest) =>
|
||||||
|
wechatShareCardService.publish(request)
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getVoiceData',
|
'db:getVoiceData',
|
||||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const normalizeRecord = async (
|
|||||||
const pngStatus = await fileStatus(record.pngPath)
|
const pngStatus = await fileStatus(record.pngPath)
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
|
textModelName: record.textModelName || record.modelName,
|
||||||
jsonPath,
|
jsonPath,
|
||||||
htmlStatus,
|
htmlStatus,
|
||||||
pngStatus,
|
pngStatus,
|
||||||
@@ -181,6 +182,8 @@ export async function saveGeneratedReport(
|
|||||||
pngStatus: savedPngPath ? 'ready' : 'missing',
|
pngStatus: savedPngPath ? 'ready' : 'missing',
|
||||||
imageSize: await readPngSize(savedPngPath),
|
imageSize: await readPngSize(savedPngPath),
|
||||||
duration: request.duration,
|
duration: request.duration,
|
||||||
|
textModelName: request.textModelName || request.modelName,
|
||||||
|
imageModelName: request.imageModelName,
|
||||||
modelName: request.modelName,
|
modelName: request.modelName,
|
||||||
tokenUsage: request.tokenUsage,
|
tokenUsage: request.tokenUsage,
|
||||||
fileSize: {
|
fileSize: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
AIProviderSummary,
|
AIProviderSummary,
|
||||||
AiSearchProviderStatus,
|
AiSearchProviderStatus,
|
||||||
AIRuntimeModelConfig,
|
AIRuntimeModelConfig,
|
||||||
|
AIVisionRuntimeConfig,
|
||||||
AIVisionTestRequest,
|
AIVisionTestRequest,
|
||||||
AIVisionTestResult,
|
AIVisionTestResult,
|
||||||
LegacyAIConfig
|
LegacyAIConfig
|
||||||
@@ -74,6 +75,60 @@ export class AIProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVisionRuntimeConfig(): AIVisionRuntimeConfig {
|
||||||
|
const result = this.list()
|
||||||
|
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
|
||||||
|
const defaultModel = defaultProvider?.models.find(
|
||||||
|
(item) => item.id === defaultProvider.defaultModel
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
defaultProvider &&
|
||||||
|
defaultModel &&
|
||||||
|
(defaultProvider.hasApiKey || !needsApiKey(defaultProvider)) &&
|
||||||
|
(defaultModel.capabilities.vision || defaultModel.capabilities.ocr)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
providerId: defaultProvider.id,
|
||||||
|
providerName: defaultProvider.name,
|
||||||
|
model: defaultModel.id,
|
||||||
|
modelName: defaultModel.name || defaultModel.id,
|
||||||
|
configured: true,
|
||||||
|
status: defaultProvider.status,
|
||||||
|
timeoutMs: defaultProvider.advanced.timeoutMs,
|
||||||
|
source: 'default-model'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of result.providers) {
|
||||||
|
if (!provider.hasApiKey && needsApiKey(provider)) continue
|
||||||
|
const model =
|
||||||
|
provider.models.find(
|
||||||
|
(item) =>
|
||||||
|
item.id === provider.defaultModel && (item.capabilities.vision || item.capabilities.ocr)
|
||||||
|
) || provider.models.find((item) => item.capabilities.vision || item.capabilities.ocr)
|
||||||
|
if (!model) continue
|
||||||
|
return {
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
model: model.id,
|
||||||
|
modelName: model.name || model.id,
|
||||||
|
configured: true,
|
||||||
|
status: provider.status,
|
||||||
|
timeoutMs: provider.advanced.timeoutMs,
|
||||||
|
source: 'vision-capability'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerName: '尚未配置',
|
||||||
|
model: '',
|
||||||
|
modelName: '尚未验证图片理解模型',
|
||||||
|
configured: false,
|
||||||
|
status: 'untested',
|
||||||
|
source: 'unavailable'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus {
|
getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus {
|
||||||
const result = this.list()
|
const result = this.list()
|
||||||
const provider =
|
const provider =
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// 设计原则:
|
// 设计原则:
|
||||||
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
||||||
// 2. 同图(imageHash)走缓存,绝不重复调 AI
|
// 2. 同图(imageHash)在 10 分钟内走缓存,过期后重新调 AI
|
||||||
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
|
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
|
||||||
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
|
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
} from '../../shared/image-insight'
|
} from '../../shared/image-insight'
|
||||||
import {
|
import {
|
||||||
calculateImageHeatScore,
|
calculateImageHeatScore,
|
||||||
|
isFreshImageInsight,
|
||||||
isHotImageCandidate
|
isHotImageCandidate
|
||||||
} from '../../shared/image-insight'
|
} from '../../shared/image-insight'
|
||||||
|
|
||||||
@@ -46,6 +47,13 @@ export interface ImageCandidateInput {
|
|||||||
|
|
||||||
interface ProviderServiceLike {
|
interface ProviderServiceLike {
|
||||||
list(): ProviderSummaryLike
|
list(): ProviderSummaryLike
|
||||||
|
getVisionRuntimeConfig(): {
|
||||||
|
providerId?: string
|
||||||
|
providerName: string
|
||||||
|
model: string
|
||||||
|
modelName: string
|
||||||
|
configured: boolean
|
||||||
|
}
|
||||||
analyzeImage(
|
analyzeImage(
|
||||||
messages: Array<{
|
messages: Array<{
|
||||||
role: string
|
role: string
|
||||||
@@ -81,7 +89,7 @@ interface ProviderSummaryLike {
|
|||||||
class ImageInsightService {
|
class ImageInsightService {
|
||||||
private providerService: ProviderServiceLike | null = null
|
private providerService: ProviderServiceLike | null = null
|
||||||
private decryptService: DecryptServiceLike | null = null
|
private decryptService: DecryptServiceLike | null = null
|
||||||
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
|
/** 最近一次实际使用的视觉 provider/model,仅用于写入分析元数据 */
|
||||||
private runtimeProviderId: string | undefined = undefined
|
private runtimeProviderId: string | undefined = undefined
|
||||||
private runtimeModelId: string | undefined = undefined
|
private runtimeModelId: string | undefined = undefined
|
||||||
|
|
||||||
@@ -97,13 +105,11 @@ class ImageInsightService {
|
|||||||
this.runtimeProviderId,
|
this.runtimeProviderId,
|
||||||
this.runtimeModelId
|
this.runtimeModelId
|
||||||
)
|
)
|
||||||
// 读取默认 provider/model(后续 analyze 时使用)
|
// 读取当前视觉 provider/model(后续 analyze 时仍会刷新,避免配置变化后继续用旧模型)
|
||||||
try {
|
try {
|
||||||
const list = deps.providerService.list()
|
const runtime = deps.providerService.getVisionRuntimeConfig()
|
||||||
const provider =
|
this.runtimeProviderId = runtime.providerId
|
||||||
list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0]
|
this.runtimeModelId = runtime.model || undefined
|
||||||
this.runtimeProviderId = provider?.id
|
|
||||||
this.runtimeModelId = provider?.defaultModel
|
|
||||||
console.log(
|
console.log(
|
||||||
'[ImageInsightService] bind loaded default provider=%s model=%s',
|
'[ImageInsightService] bind loaded default provider=%s model=%s',
|
||||||
this.runtimeProviderId,
|
this.runtimeProviderId,
|
||||||
@@ -145,7 +151,7 @@ class ImageInsightService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 主入口:分析一张图片。
|
* 主入口:分析一张图片。
|
||||||
* 1. 通过 imageHash 查缓存,命中即返回
|
* 1. 通过 imageHash 查 10 分钟缓存,新鲜则返回
|
||||||
* 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
|
* 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
|
||||||
* 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
|
* 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
|
||||||
*/
|
*/
|
||||||
@@ -153,8 +159,15 @@ class ImageInsightService {
|
|||||||
try {
|
try {
|
||||||
if (!request.force) {
|
if (!request.force) {
|
||||||
const cached = imageInsightsStore.getByHash(request.imageHash)
|
const cached = imageInsightsStore.getByHash(request.imageHash)
|
||||||
|
if (isFreshImageInsight(cached)) {
|
||||||
|
return { success: true, insight: cached || undefined, fromCache: true }
|
||||||
|
}
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return { success: true, insight: cached, fromCache: true }
|
console.log(
|
||||||
|
'[ImageInsightService] cache expired hash=%s ageMs=%d',
|
||||||
|
request.imageHash,
|
||||||
|
Date.now() - Number(cached.updatedAt || 0)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,11 +197,24 @@ class ImageInsightService {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const list = this.providerService.list()
|
const runtime =
|
||||||
const provider =
|
request.providerId && request.modelId
|
||||||
list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0]
|
? {
|
||||||
this.runtimeProviderId = provider?.id
|
providerId: request.providerId,
|
||||||
this.runtimeModelId = provider?.defaultModel
|
model: request.modelId,
|
||||||
|
configured: true
|
||||||
|
}
|
||||||
|
: this.providerService.getVisionRuntimeConfig()
|
||||||
|
if (!runtime.configured || !runtime.providerId || !runtime.model) {
|
||||||
|
return { success: false, error: '尚未配置或验证支持图片理解的 AI 模型' }
|
||||||
|
}
|
||||||
|
this.runtimeProviderId = runtime.providerId
|
||||||
|
this.runtimeModelId = runtime.model
|
||||||
|
console.log(
|
||||||
|
'[ImageInsightService] analyze using vision provider=%s model=%s',
|
||||||
|
this.runtimeProviderId,
|
||||||
|
this.runtimeModelId
|
||||||
|
)
|
||||||
const result = await this.providerService.analyzeImage(messages, {
|
const result = await this.providerService.analyzeImage(messages, {
|
||||||
providerId: this.runtimeProviderId,
|
providerId: this.runtimeProviderId,
|
||||||
modelId: this.runtimeModelId
|
modelId: this.runtimeModelId
|
||||||
@@ -273,7 +299,7 @@ class ImageInsightService {
|
|||||||
heatScore
|
heatScore
|
||||||
}
|
}
|
||||||
const cached = imageInsightsStore.getByHash(hash)
|
const cached = imageInsightsStore.getByHash(hash)
|
||||||
if (cached) candidate.insight = cached
|
if (isFreshImageInsight(cached) && cached) candidate.insight = cached
|
||||||
candidates.push(candidate)
|
candidates.push(candidate)
|
||||||
}
|
}
|
||||||
candidates.sort((a, b) => b.heatScore - a.heatScore)
|
candidates.sort((a, b) => b.heatScore - a.heatScore)
|
||||||
@@ -286,7 +312,6 @@ class ImageInsightService {
|
|||||||
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||||
return imageInsightsStore.listBySession(sessionId, limit)
|
return imageInsightsStore.listBySession(sessionId, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imageInsightService = new ImageInsightService()
|
export const imageInsightService = new ImageInsightService()
|
||||||
|
|||||||
@@ -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,
|
KnowledgeSearchIpcRequest,
|
||||||
KnowledgeSearchIpcResult
|
KnowledgeSearchIpcResult
|
||||||
} from '../shared/knowledge'
|
} from '../shared/knowledge'
|
||||||
|
import type {
|
||||||
|
PublishWechatShareCardRequest,
|
||||||
|
PublishWechatShareCardResult,
|
||||||
|
WechatShareServiceConfig,
|
||||||
|
WechatShareServiceConfigResult
|
||||||
|
} from '../shared/wechat-share-card'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -327,6 +333,13 @@ declare global {
|
|||||||
) => Promise<UpdateGeneratedReportTemplateResult>
|
) => Promise<UpdateGeneratedReportTemplateResult>
|
||||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
getWechatShareConfig: () => Promise<WechatShareServiceConfigResult>
|
||||||
|
saveWechatShareConfig: (
|
||||||
|
config: WechatShareServiceConfig
|
||||||
|
) => Promise<WechatShareServiceConfigResult>
|
||||||
|
publishWechatShareCard: (
|
||||||
|
request: PublishWechatShareCardRequest
|
||||||
|
) => Promise<PublishWechatShareCardResult>
|
||||||
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||||
readDatabaseKeyClipboard: () => Promise<{
|
readDatabaseKeyClipboard: () => Promise<{
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ import type {
|
|||||||
KnowledgeSearchIpcRequest,
|
KnowledgeSearchIpcRequest,
|
||||||
KnowledgeSearchIpcResult
|
KnowledgeSearchIpcResult
|
||||||
} from '../shared/knowledge'
|
} from '../shared/knowledge'
|
||||||
|
import type {
|
||||||
|
PublishWechatShareCardRequest,
|
||||||
|
WechatShareServiceConfig
|
||||||
|
} from '../shared/wechat-share-card'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -224,6 +228,11 @@ const api = {
|
|||||||
deleteGeneratedReport: (reportId: string) =>
|
deleteGeneratedReport: (reportId: string) =>
|
||||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||||
|
getWechatShareConfig: () => ipcRenderer.invoke('wechat-share:getConfig'),
|
||||||
|
saveWechatShareConfig: (config: WechatShareServiceConfig) =>
|
||||||
|
ipcRenderer.invoke('wechat-share:saveConfig', config),
|
||||||
|
publishWechatShareCard: (request: PublishWechatShareCardRequest) =>
|
||||||
|
ipcRenderer.invoke('wechat-share:publish', request),
|
||||||
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
|
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
|
||||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||||
|
|||||||
+132
-8
@@ -6,7 +6,11 @@ import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
|||||||
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
||||||
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
|
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
|
||||||
import type { SettingsCategoryId } from './features/settings/model/types'
|
import type { SettingsCategoryId } from './features/settings/model/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
|
import type {
|
||||||
|
AIProviderSummary,
|
||||||
|
AIRuntimeModelConfig,
|
||||||
|
ReportModelChoice
|
||||||
|
} from '../../shared/ai-provider'
|
||||||
import { AppPage } from './components/layout/navigation'
|
import { AppPage } from './components/layout/navigation'
|
||||||
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
||||||
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
||||||
@@ -73,6 +77,51 @@ const INITIAL_MESSAGE_COUNT = 20
|
|||||||
const MESSAGE_PAGE_SIZE = 100
|
const MESSAGE_PAGE_SIZE = 100
|
||||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||||
const EXPORT_PREVIEW_LIMIT = 20
|
const EXPORT_PREVIEW_LIMIT = 20
|
||||||
|
const REPORT_TEXT_MODEL_STORAGE_KEY = 'group_report_text_model'
|
||||||
|
const REPORT_VISION_MODEL_STORAGE_KEY = 'group_report_vision_model'
|
||||||
|
|
||||||
|
const reportModelKey = (model: { providerId?: string; model: string }): string =>
|
||||||
|
model.providerId && model.model ? `${model.providerId}::${model.model}` : ''
|
||||||
|
|
||||||
|
const reportProviderConfigured = (provider: AIProviderSummary): boolean =>
|
||||||
|
Boolean(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
|
||||||
|
|
||||||
|
const reportModelChoices = (
|
||||||
|
providers: AIProviderSummary[],
|
||||||
|
capability: 'chat' | 'vision'
|
||||||
|
): ReportModelChoice[] =>
|
||||||
|
providers.flatMap((provider) => {
|
||||||
|
if (!reportProviderConfigured(provider)) return []
|
||||||
|
return provider.models
|
||||||
|
.filter((model) =>
|
||||||
|
capability === 'chat'
|
||||||
|
? model.capabilities.chat
|
||||||
|
: model.capabilities.vision || model.capabilities.ocr
|
||||||
|
)
|
||||||
|
.map((model) => ({
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
model: model.id,
|
||||||
|
modelName: model.name || model.id,
|
||||||
|
configured: true as const,
|
||||||
|
status: provider.status,
|
||||||
|
timeoutMs: provider.advanced.timeoutMs
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectReportModel = (
|
||||||
|
choices: ReportModelChoice[],
|
||||||
|
storageKey: string,
|
||||||
|
fallback: { providerId?: string; model: string }
|
||||||
|
): ReportModelChoice | undefined => {
|
||||||
|
const savedKey = localStorage.getItem(storageKey) || ''
|
||||||
|
const fallbackKey = reportModelKey(fallback)
|
||||||
|
return (
|
||||||
|
choices.find((choice) => reportModelKey(choice) === savedKey) ||
|
||||||
|
choices.find((choice) => reportModelKey(choice) === fallbackKey) ||
|
||||||
|
choices[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||||
if (left === right) return true
|
if (left === right) return true
|
||||||
if (left.length !== right.length) return false
|
if (left.length !== right.length) return false
|
||||||
@@ -209,6 +258,16 @@ function App(): React.ReactElement {
|
|||||||
configured: false,
|
configured: false,
|
||||||
status: 'untested'
|
status: 'untested'
|
||||||
})
|
})
|
||||||
|
const [reportTextModelConfig, setReportTextModelConfig] = useState<AiModelConfig>({
|
||||||
|
providerName: '尚未配置',
|
||||||
|
model: '',
|
||||||
|
modelName: '尚未选择模型',
|
||||||
|
configured: false,
|
||||||
|
status: 'untested'
|
||||||
|
})
|
||||||
|
const [aiVisionModelConfig, setAiVisionModelConfig] = useState<ReportModelChoice>()
|
||||||
|
const [reportTextModelOptions, setReportTextModelOptions] = useState<ReportModelChoice[]>([])
|
||||||
|
const [reportVisionModelOptions, setReportVisionModelOptions] = useState<ReportModelChoice[]>([])
|
||||||
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||||
@@ -336,7 +395,25 @@ function App(): React.ReactElement {
|
|||||||
localStorage.removeItem('ai_model')
|
localStorage.removeItem('ai_model')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAiModelConfig(await window.api.getAIRuntimeConfig())
|
const [runtime, visionRuntime, providerList] = await Promise.all([
|
||||||
|
window.api.getAIRuntimeConfig(),
|
||||||
|
window.api.getAIVisionRuntimeConfig(),
|
||||||
|
window.api.listAIProviders()
|
||||||
|
])
|
||||||
|
const providers = providerList.success ? providerList.providers : []
|
||||||
|
const textChoices = reportModelChoices(providers, 'chat')
|
||||||
|
const visionChoices = reportModelChoices(providers, 'vision')
|
||||||
|
const selectedText = selectReportModel(textChoices, REPORT_TEXT_MODEL_STORAGE_KEY, runtime)
|
||||||
|
const selectedVision = selectReportModel(
|
||||||
|
visionChoices,
|
||||||
|
REPORT_VISION_MODEL_STORAGE_KEY,
|
||||||
|
visionRuntime
|
||||||
|
)
|
||||||
|
setReportTextModelOptions(textChoices)
|
||||||
|
setReportVisionModelOptions(visionChoices)
|
||||||
|
setAiModelConfig(runtime)
|
||||||
|
setReportTextModelConfig(selectedText || runtime)
|
||||||
|
setAiVisionModelConfig(selectedVision)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[AI Provider] 配置加载失败:', error)
|
console.warn('[AI Provider] 配置加载失败:', error)
|
||||||
}
|
}
|
||||||
@@ -349,7 +426,8 @@ function App(): React.ReactElement {
|
|||||||
sourceContact: reportSourceContact,
|
sourceContact: reportSourceContact,
|
||||||
summaryDateRange,
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
modelConfig: aiModelConfig
|
modelConfig: reportTextModelConfig,
|
||||||
|
visionModelConfig: aiVisionModelConfig
|
||||||
})
|
})
|
||||||
const lastCapturedReportKeyRef = React.useRef('')
|
const lastCapturedReportKeyRef = React.useRef('')
|
||||||
|
|
||||||
@@ -1496,7 +1574,10 @@ function App(): React.ReactElement {
|
|||||||
htmlPath: reportGeneration.reportPaths?.htmlPath,
|
htmlPath: reportGeneration.reportPaths?.htmlPath,
|
||||||
pngPath: reportGeneration.reportPaths?.pngPath,
|
pngPath: reportGeneration.reportPaths?.pngPath,
|
||||||
duration: reportGeneration.generationMetadata.durationMs,
|
duration: reportGeneration.generationMetadata.durationMs,
|
||||||
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
|
textModelName:
|
||||||
|
reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
||||||
|
imageModelName: aiVisionModelConfig?.modelName || aiVisionModelConfig?.model,
|
||||||
|
modelName: reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
||||||
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
|
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
|
||||||
generationLogs: reportGeneration.generationMetadata.generationLogs,
|
generationLogs: reportGeneration.generationMetadata.generationLogs,
|
||||||
reportSnapshot,
|
reportSnapshot,
|
||||||
@@ -1521,7 +1602,10 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
void saveReport()
|
void saveReport()
|
||||||
}, [
|
}, [
|
||||||
aiModelConfig.model,
|
aiVisionModelConfig?.model,
|
||||||
|
aiVisionModelConfig?.modelName,
|
||||||
|
reportTextModelConfig.model,
|
||||||
|
reportTextModelConfig.modelName,
|
||||||
reportGeneration.generatedImage,
|
reportGeneration.generatedImage,
|
||||||
reportGeneration.generationMetadata,
|
reportGeneration.generationMetadata,
|
||||||
reportGeneration.phase,
|
reportGeneration.phase,
|
||||||
@@ -1689,7 +1773,10 @@ function App(): React.ReactElement {
|
|||||||
sourceContact={reportSourceContact}
|
sourceContact={reportSourceContact}
|
||||||
summaryDateRange={summaryDateRange}
|
summaryDateRange={summaryDateRange}
|
||||||
summaryMessageTypes={summaryMessageTypes}
|
summaryMessageTypes={summaryMessageTypes}
|
||||||
modelConfig={aiModelConfig}
|
modelConfig={reportTextModelConfig}
|
||||||
|
visionModelConfig={aiVisionModelConfig}
|
||||||
|
textModelOptions={reportTextModelOptions}
|
||||||
|
visionModelOptions={reportVisionModelOptions}
|
||||||
rangeMessageCount={reportGeneration.rangeMessages.length}
|
rangeMessageCount={reportGeneration.rangeMessages.length}
|
||||||
reportMessageCount={reportGeneration.reportMessages.length}
|
reportMessageCount={reportGeneration.reportMessages.length}
|
||||||
messageTypeCounts={reportGeneration.messageTypeCounts}
|
messageTypeCounts={reportGeneration.messageTypeCounts}
|
||||||
@@ -1702,6 +1789,14 @@ function App(): React.ReactElement {
|
|||||||
onSummaryDateRangeChange={setSummaryDateRange}
|
onSummaryDateRangeChange={setSummaryDateRange}
|
||||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
||||||
onOpenModelSettings={openModelSettings}
|
onOpenModelSettings={openModelSettings}
|
||||||
|
onTextModelChange={(model) => {
|
||||||
|
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
|
||||||
|
setReportTextModelConfig(model)
|
||||||
|
}}
|
||||||
|
onVisionModelChange={(model) => {
|
||||||
|
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
|
||||||
|
setAiVisionModelConfig(model)
|
||||||
|
}}
|
||||||
onGenerate={() => {
|
onGenerate={() => {
|
||||||
reportGeneration.resetGenerationStatus()
|
reportGeneration.resetGenerationStatus()
|
||||||
void reportGeneration.generate()
|
void reportGeneration.generate()
|
||||||
@@ -1726,7 +1821,7 @@ function App(): React.ReactElement {
|
|||||||
preparationProgress={reportGeneration.preparationProgress}
|
preparationProgress={reportGeneration.preparationProgress}
|
||||||
imageInsightSummary={reportGeneration.imageInsightSummary}
|
imageInsightSummary={reportGeneration.imageInsightSummary}
|
||||||
canRetryModelStep={reportGeneration.canRetryModelStep}
|
canRetryModelStep={reportGeneration.canRetryModelStep}
|
||||||
currentModel={aiModelConfig}
|
currentModel={reportTextModelConfig}
|
||||||
onRetry={(model) => void reportGeneration.retry(model)}
|
onRetry={(model) => void reportGeneration.retry(model)}
|
||||||
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
||||||
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
||||||
@@ -1765,7 +1860,36 @@ function App(): React.ReactElement {
|
|||||||
onContactsChange={setContacts}
|
onContactsChange={setContacts}
|
||||||
onFilteredContactsChange={setFilteredContacts}
|
onFilteredContactsChange={setFilteredContacts}
|
||||||
onReturnToLogin={handleReturnToLogin}
|
onReturnToLogin={handleReturnToLogin}
|
||||||
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
|
onAIRuntimeChange={(config: AIRuntimeModelConfig) => {
|
||||||
|
setAiModelConfig(config)
|
||||||
|
void Promise.all([
|
||||||
|
window.api.getAIVisionRuntimeConfig(),
|
||||||
|
window.api.listAIProviders()
|
||||||
|
])
|
||||||
|
.then(([visionRuntime, providerList]) => {
|
||||||
|
const providers = providerList.success ? providerList.providers : []
|
||||||
|
const textChoices = reportModelChoices(providers, 'chat')
|
||||||
|
const visionChoices = reportModelChoices(providers, 'vision')
|
||||||
|
setReportTextModelOptions(textChoices)
|
||||||
|
setReportVisionModelOptions(visionChoices)
|
||||||
|
setReportTextModelConfig(
|
||||||
|
(current) =>
|
||||||
|
selectReportModel(
|
||||||
|
textChoices,
|
||||||
|
REPORT_TEXT_MODEL_STORAGE_KEY,
|
||||||
|
current.configured ? current : config
|
||||||
|
) || config
|
||||||
|
)
|
||||||
|
setAiVisionModelConfig((current) =>
|
||||||
|
selectReportModel(
|
||||||
|
visionChoices,
|
||||||
|
REPORT_VISION_MODEL_STORAGE_KEY,
|
||||||
|
current || visionRuntime
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}}
|
||||||
onNotice={setReportNotice}
|
onNotice={setReportNotice}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
onAppearanceChange={handleAppearanceChange}
|
onAppearanceChange={handleAppearanceChange}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import type { ReportModelChoice } from '../../../../shared/ai-provider'
|
||||||
import {
|
import {
|
||||||
AiModelConfig,
|
AiModelConfig,
|
||||||
RangeMessageState,
|
RangeMessageState,
|
||||||
@@ -22,6 +23,9 @@ interface AiReportWorkspaceProps {
|
|||||||
summaryDateRange: SummaryDateRange
|
summaryDateRange: SummaryDateRange
|
||||||
summaryMessageTypes: SummaryMessageType[]
|
summaryMessageTypes: SummaryMessageType[]
|
||||||
modelConfig: AiModelConfig
|
modelConfig: AiModelConfig
|
||||||
|
visionModelConfig?: ReportModelChoice
|
||||||
|
textModelOptions: ReportModelChoice[]
|
||||||
|
visionModelOptions: ReportModelChoice[]
|
||||||
rangeMessageCount: number
|
rangeMessageCount: number
|
||||||
reportMessageCount: number
|
reportMessageCount: number
|
||||||
messageTypeCounts: Record<SummaryMessageType, number>
|
messageTypeCounts: Record<SummaryMessageType, number>
|
||||||
@@ -34,6 +38,8 @@ interface AiReportWorkspaceProps {
|
|||||||
onSummaryDateRangeChange: (value: SummaryDateRange) => void
|
onSummaryDateRangeChange: (value: SummaryDateRange) => void
|
||||||
onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void
|
onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void
|
||||||
onOpenModelSettings: () => void
|
onOpenModelSettings: () => void
|
||||||
|
onTextModelChange: (model: ReportModelChoice) => void
|
||||||
|
onVisionModelChange: (model: ReportModelChoice) => void
|
||||||
onGenerate: () => void
|
onGenerate: () => void
|
||||||
onCloseResult: () => void
|
onCloseResult: () => void
|
||||||
onCopyImage: () => Promise<{ success: boolean; error?: string }>
|
onCopyImage: () => Promise<{ success: boolean; error?: string }>
|
||||||
@@ -69,6 +75,9 @@ export function AiReportWorkspace({
|
|||||||
summaryDateRange,
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
modelConfig,
|
modelConfig,
|
||||||
|
visionModelConfig,
|
||||||
|
textModelOptions,
|
||||||
|
visionModelOptions,
|
||||||
rangeMessageCount,
|
rangeMessageCount,
|
||||||
reportMessageCount,
|
reportMessageCount,
|
||||||
messageTypeCounts,
|
messageTypeCounts,
|
||||||
@@ -81,6 +90,8 @@ export function AiReportWorkspace({
|
|||||||
onSummaryDateRangeChange,
|
onSummaryDateRangeChange,
|
||||||
onSummaryMessageTypesChange,
|
onSummaryMessageTypesChange,
|
||||||
onOpenModelSettings,
|
onOpenModelSettings,
|
||||||
|
onTextModelChange,
|
||||||
|
onVisionModelChange,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onCloseResult,
|
onCloseResult,
|
||||||
onCopyImage,
|
onCopyImage,
|
||||||
@@ -171,7 +182,16 @@ export function AiReportWorkspace({
|
|||||||
disabled={configDisabled}
|
disabled={configDisabled}
|
||||||
/>
|
/>
|
||||||
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
|
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
|
||||||
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
<ModelSummary
|
||||||
|
config={modelConfig}
|
||||||
|
visionConfig={visionModelConfig}
|
||||||
|
textModels={textModelOptions}
|
||||||
|
visionModels={visionModelOptions}
|
||||||
|
disabled={configDisabled}
|
||||||
|
onTextModelChange={onTextModelChange}
|
||||||
|
onVisionModelChange={onVisionModelChange}
|
||||||
|
onOpenSettings={onOpenModelSettings}
|
||||||
|
/>
|
||||||
<section className="report-config-section report-timeout-section">
|
<section className="report-config-section report-timeout-section">
|
||||||
<div>
|
<div>
|
||||||
<h3>日报生成超时</h3>
|
<h3>日报生成超时</h3>
|
||||||
|
|||||||
@@ -1,24 +1,87 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import type { ReportModelChoice } from '../../../../shared/ai-provider'
|
||||||
import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
|
import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
|
||||||
|
|
||||||
interface ModelSummaryProps {
|
interface ModelSummaryProps {
|
||||||
config: AiModelConfig
|
config: AiModelConfig
|
||||||
|
visionConfig?: ReportModelChoice
|
||||||
|
textModels: ReportModelChoice[]
|
||||||
|
visionModels: ReportModelChoice[]
|
||||||
|
disabled?: boolean
|
||||||
|
onTextModelChange: (model: ReportModelChoice) => void
|
||||||
|
onVisionModelChange: (model: ReportModelChoice) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement {
|
const modelKey = (model: { providerId?: string; model: string } | undefined): string =>
|
||||||
const statusText = config.configured ? '配置正常' : '尚未配置'
|
model?.providerId && model.model ? `${model.providerId}::${model.model}` : ''
|
||||||
|
|
||||||
|
const optionLabel = (model: ReportModelChoice): string =>
|
||||||
|
`${model.providerName} · ${model.modelName || model.model}`
|
||||||
|
|
||||||
|
export function ModelSummary({
|
||||||
|
config,
|
||||||
|
visionConfig,
|
||||||
|
textModels,
|
||||||
|
visionModels,
|
||||||
|
disabled = false,
|
||||||
|
onTextModelChange,
|
||||||
|
onVisionModelChange,
|
||||||
|
onOpenSettings
|
||||||
|
}: ModelSummaryProps): React.ReactElement {
|
||||||
|
const changeModel = (
|
||||||
|
key: string,
|
||||||
|
models: ReportModelChoice[],
|
||||||
|
onChange: (model: ReportModelChoice) => void
|
||||||
|
): void => {
|
||||||
|
const selected = models.find((model) => modelKey(model) === key)
|
||||||
|
if (selected) onChange(selected)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="report-config-section">
|
<section className="report-config-section">
|
||||||
<div className="report-model-summary">
|
<div className="report-model-summary">
|
||||||
<div>
|
<div className="report-model-summary-content">
|
||||||
<h3>模型配置</h3>
|
<h3>模型配置</h3>
|
||||||
<p>
|
<div className="report-model-selects">
|
||||||
{config.modelName || config.model || '未选择模型'} · {statusText}
|
<label>
|
||||||
</p>
|
<span>文字总结模型</span>
|
||||||
|
<select
|
||||||
|
aria-label="文字总结模型"
|
||||||
|
value={modelKey(config)}
|
||||||
|
disabled={disabled || !textModels.length}
|
||||||
|
onChange={(event) => changeModel(event.target.value, textModels, onTextModelChange)}
|
||||||
|
>
|
||||||
|
{!textModels.length && <option value="">没有已配置的文字模型</option>}
|
||||||
|
{textModels.map((model) => (
|
||||||
|
<option key={modelKey(model)} value={modelKey(model)}>
|
||||||
|
{optionLabel(model)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>图片理解模型</span>
|
||||||
|
<select
|
||||||
|
aria-label="图片理解模型"
|
||||||
|
value={modelKey(visionConfig)}
|
||||||
|
disabled={disabled || !visionModels.length}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeModel(event.target.value, visionModels, onVisionModelChange)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!visionModels.length && <option value="">没有已验证的图片理解模型</option>}
|
||||||
|
{visionModels.map((model) => (
|
||||||
|
<option key={modelKey(model)} value={modelKey(model)}>
|
||||||
|
{optionLabel(model)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onOpenSettings}>
|
<small>图片识别缓存 10 分钟;识图完成后仍由文字总结模型生成日报。</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onOpenSettings} disabled={disabled}>
|
||||||
更改模型
|
更改模型
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,8 +108,12 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea
|
|||||||
<b>{formatDuration(report.duration)}</b>
|
<b>{formatDuration(report.duration)}</b>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>模型</span>
|
<span>文字模型</span>
|
||||||
<b>{modelLabel(report.modelName)}</b>
|
<b>{modelLabel(report.textModelName || report.modelName)}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>图片模型</span>
|
||||||
|
<b>{modelLabel(report.imageModelName)}</b>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Token 来源</span>
|
<span>Token 来源</span>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
interface ReportToolbarProps {
|
interface ReportToolbarProps {
|
||||||
canCopyImage: boolean
|
canCopyImage: boolean
|
||||||
canReveal: boolean
|
canReveal: boolean
|
||||||
|
canShare: boolean
|
||||||
canSwitchTemplate: boolean
|
canSwitchTemplate: boolean
|
||||||
currentTemplateId?: SelectableReportTemplateId
|
currentTemplateId?: SelectableReportTemplateId
|
||||||
isSwitchingTemplate: boolean
|
isSwitchingTemplate: boolean
|
||||||
@@ -14,18 +15,21 @@ interface ReportToolbarProps {
|
|||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
onCopyImage: () => void
|
onCopyImage: () => void
|
||||||
onReveal: () => void
|
onReveal: () => void
|
||||||
|
onShare: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportToolbar({
|
export function ReportToolbar({
|
||||||
canCopyImage,
|
canCopyImage,
|
||||||
canReveal,
|
canReveal,
|
||||||
|
canShare,
|
||||||
canSwitchTemplate,
|
canSwitchTemplate,
|
||||||
currentTemplateId,
|
currentTemplateId,
|
||||||
isSwitchingTemplate,
|
isSwitchingTemplate,
|
||||||
onSwitchTemplate,
|
onSwitchTemplate,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onCopyImage,
|
onCopyImage,
|
||||||
onReveal
|
onReveal,
|
||||||
|
onShare
|
||||||
}: ReportToolbarProps): React.ReactElement {
|
}: ReportToolbarProps): React.ReactElement {
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
const [templateOpen, setTemplateOpen] = useState(false)
|
const [templateOpen, setTemplateOpen] = useState(false)
|
||||||
@@ -88,6 +92,9 @@ export function ReportToolbar({
|
|||||||
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
|
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
|
||||||
打开报告
|
打开报告
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" disabled={!canShare} onClick={onShare}>
|
||||||
|
生成微信卡片
|
||||||
|
</button>
|
||||||
<div className="report-more-menu" ref={menuRef}>
|
<div className="report-more-menu" ref={menuRef}>
|
||||||
<button type="button" onClick={() => setMoreOpen((open) => !open)}>
|
<button type="button" onClick={() => setMoreOpen((open) => !open)}>
|
||||||
更多
|
更多
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ReportEmptyState } from './ReportEmptyState'
|
|||||||
import { ReportToolbar } from './ReportToolbar'
|
import { ReportToolbar } from './ReportToolbar'
|
||||||
import { ReportZoomBar } from './ReportZoomBar'
|
import { ReportZoomBar } from './ReportZoomBar'
|
||||||
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||||
|
import { WechatShareCardDialog } from './WechatShareCardDialog'
|
||||||
|
|
||||||
interface ReportViewerProps {
|
interface ReportViewerProps {
|
||||||
report: GeneratedReportRecord | null
|
report: GeneratedReportRecord | null
|
||||||
@@ -49,6 +50,7 @@ export function ReportViewer({
|
|||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [imageError, setImageError] = useState('')
|
const [imageError, setImageError] = useState('')
|
||||||
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
|
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
|
||||||
|
const [shareDialogOpen, setShareDialogOpen] = useState(false)
|
||||||
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ export function ReportViewer({
|
|||||||
setStatus('')
|
setStatus('')
|
||||||
setImageError('')
|
setImageError('')
|
||||||
setIsSwitchingTemplate(false)
|
setIsSwitchingTemplate(false)
|
||||||
|
setShareDialogOpen(false)
|
||||||
setZoom(1)
|
setZoom(1)
|
||||||
setFitZoom(1)
|
setFitZoom(1)
|
||||||
setNaturalSize(null)
|
setNaturalSize(null)
|
||||||
@@ -165,6 +168,7 @@ export function ReportViewer({
|
|||||||
<ReportToolbar
|
<ReportToolbar
|
||||||
canCopyImage={Boolean(report.generatedImage)}
|
canCopyImage={Boolean(report.generatedImage)}
|
||||||
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
||||||
|
canShare={Boolean(report.pngPath)}
|
||||||
canSwitchTemplate={Boolean(
|
canSwitchTemplate={Boolean(
|
||||||
(report.reportSnapshot && report.reportMetadata) ||
|
(report.reportSnapshot && report.reportMetadata) ||
|
||||||
report.reportRenderSnapshot ||
|
report.reportRenderSnapshot ||
|
||||||
@@ -176,6 +180,7 @@ export function ReportViewer({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onCopyImage={() => void handleCopy()}
|
onCopyImage={() => void handleCopy()}
|
||||||
onReveal={() => void handleReveal()}
|
onReveal={() => void handleReveal()}
|
||||||
|
onShare={() => setShareDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
{status && <div className="report-viewer-status">{status}</div>}
|
{status && <div className="report-viewer-status">{status}</div>}
|
||||||
@@ -233,6 +238,14 @@ export function ReportViewer({
|
|||||||
onFitPage={fitPage}
|
onFitPage={fitPage}
|
||||||
onActualSize={showActualSize}
|
onActualSize={showActualSize}
|
||||||
/>
|
/>
|
||||||
|
{shareDialogOpen && report.pngPath && (
|
||||||
|
<WechatShareCardDialog
|
||||||
|
pngPath={report.pngPath}
|
||||||
|
initialTitle={`${report.contactName}日报 · ${report.reportDate}`}
|
||||||
|
initialDescription={`基于 ${report.messageCount} 条群聊消息生成的 AI 日报`}
|
||||||
|
onClose={() => setShareDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import type { PublishWechatShareCardResult } from '../../../../shared/wechat-share-card'
|
||||||
|
|
||||||
|
interface WechatShareCardDialogProps {
|
||||||
|
pngPath: string
|
||||||
|
initialTitle: string
|
||||||
|
initialDescription: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WechatShareCardDialog({
|
||||||
|
pngPath,
|
||||||
|
initialTitle,
|
||||||
|
initialDescription,
|
||||||
|
onClose
|
||||||
|
}: WechatShareCardDialogProps): React.ReactElement {
|
||||||
|
const [title, setTitle] = useState(initialTitle)
|
||||||
|
const [description, setDescription] = useState(initialDescription)
|
||||||
|
const [serviceUrl, setServiceUrl] = useState('https://share.example.com')
|
||||||
|
const [uploadToken, setUploadToken] = useState('')
|
||||||
|
const [configured, setConfigured] = useState<boolean | null>(null)
|
||||||
|
const [editingConfig, setEditingConfig] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [result, setResult] = useState<PublishWechatShareCardResult | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void window.api.getWechatShareConfig().then((response) => {
|
||||||
|
setConfigured(Boolean(response.success && response.configured))
|
||||||
|
if (response.serviceUrl) setServiceUrl(response.serviceUrl)
|
||||||
|
if (!response.success) setError(response.error || '读取卡片服务配置失败')
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
const closeOnEscape = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape' && !busy) onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', closeOnEscape)
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
window.removeEventListener('keydown', closeOnEscape)
|
||||||
|
}
|
||||||
|
}, [busy, onClose])
|
||||||
|
|
||||||
|
const publish = async (): Promise<void> => {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
if (!configured || editingConfig) {
|
||||||
|
const saved = await window.api.saveWechatShareConfig({ serviceUrl, uploadToken })
|
||||||
|
if (!saved.success) {
|
||||||
|
setError(saved.error || '保存卡片服务配置失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConfigured(true)
|
||||||
|
setEditingConfig(false)
|
||||||
|
}
|
||||||
|
const published = await window.api.publishWechatShareCard({
|
||||||
|
pngPath,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
expiresInDays: 7
|
||||||
|
})
|
||||||
|
if (!published.success) {
|
||||||
|
setError(published.error || '微信卡片生成失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResult(published)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyLink = async (): Promise<void> => {
|
||||||
|
if (!result?.shareUrl) return
|
||||||
|
await navigator.clipboard.writeText(result.shareUrl)
|
||||||
|
setCopied(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="wechat-share-dialog-backdrop" role="presentation" onMouseDown={onClose}>
|
||||||
|
<section
|
||||||
|
className="wechat-share-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="wechat-share-title"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h2 id="wechat-share-title">生成微信分享卡片</h2>
|
||||||
|
<p>卡片和日报将在 7 天后自动失效。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="wechat-share-dialog-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{result?.qrCodeDataUrl ? (
|
||||||
|
<div className="wechat-share-success">
|
||||||
|
<img src={result.qrCodeDataUrl} alt="微信分享二维码" />
|
||||||
|
<h3>使用微信扫码</h3>
|
||||||
|
<p>打开页面后点击右上角 ···,发送给好友或群聊。</p>
|
||||||
|
{result.expiresAt && (
|
||||||
|
<small>有效期至 {new Date(result.expiresAt).toLocaleString('zh-CN')}</small>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => void copyLink()}>
|
||||||
|
{copied ? '链接已复制' : '复制分享链接'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="primary" onClick={onClose}>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="wechat-share-form">
|
||||||
|
<label>
|
||||||
|
<span>卡片标题</span>
|
||||||
|
<input
|
||||||
|
maxLength={64}
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>卡片描述</span>
|
||||||
|
<textarea
|
||||||
|
maxLength={120}
|
||||||
|
rows={3}
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{configured === true && !editingConfig && (
|
||||||
|
<div className="wechat-share-service-summary">
|
||||||
|
<div>
|
||||||
|
<span>卡片服务</span>
|
||||||
|
<b>{serviceUrl}</b>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setEditingConfig(true)}>
|
||||||
|
更改
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(configured === false || editingConfig) && (
|
||||||
|
<div className="wechat-share-service-config">
|
||||||
|
<h3>首次配置卡片服务</h3>
|
||||||
|
<label>
|
||||||
|
<span>服务地址</span>
|
||||||
|
<input
|
||||||
|
value={serviceUrl}
|
||||||
|
onChange={(event) => setServiceUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>上传密钥</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={uploadToken}
|
||||||
|
onChange={(event) => setUploadToken(event.target.value)}
|
||||||
|
placeholder="Cloudflare Worker 的 UPLOAD_TOKEN"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p>上传密钥仅加密保存在本机,不是公众号 AppSecret。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="wechat-share-privacy">
|
||||||
|
生成后会将当前日报长图和缩略图上传到你的私有 R2 存储。
|
||||||
|
</div>
|
||||||
|
{error && <p className="report-inline-error">{error}</p>}
|
||||||
|
<footer>
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
disabled={
|
||||||
|
busy ||
|
||||||
|
configured === null ||
|
||||||
|
!title.trim() ||
|
||||||
|
((!configured || editingConfig) && uploadToken.trim().length < 24)
|
||||||
|
}
|
||||||
|
onClick={() => void publish()}
|
||||||
|
>
|
||||||
|
{busy ? '正在生成卡片…' : '生成二维码'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '../utils/voice-message-reference'
|
} from '../utils/voice-message-reference'
|
||||||
import type { VoiceModelStatus } from '../../../shared/voice-recognition'
|
import type { VoiceModelStatus } from '../../../shared/voice-recognition'
|
||||||
import { resolveMemberName } from '../../../shared/member-names'
|
import { resolveMemberName } from '../../../shared/member-names'
|
||||||
|
import type { ReportModelChoice } from '../../../shared/ai-provider'
|
||||||
|
|
||||||
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
|
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ interface UseGroupReportGenerationArgs {
|
|||||||
summaryDateRange: SummaryDateRange
|
summaryDateRange: SummaryDateRange
|
||||||
summaryMessageTypes: SummaryMessageType[]
|
summaryMessageTypes: SummaryMessageType[]
|
||||||
modelConfig: AiModelConfig
|
modelConfig: AiModelConfig
|
||||||
|
visionModelConfig?: ReportModelChoice
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreparedReportContext {
|
interface PreparedReportContext {
|
||||||
@@ -260,7 +262,8 @@ export function useGroupReportGeneration({
|
|||||||
sourceContact,
|
sourceContact,
|
||||||
summaryDateRange,
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
modelConfig
|
modelConfig,
|
||||||
|
visionModelConfig
|
||||||
}: UseGroupReportGenerationArgs): {
|
}: UseGroupReportGenerationArgs): {
|
||||||
phase: ReportGenerationPhase
|
phase: ReportGenerationPhase
|
||||||
error: string
|
error: string
|
||||||
@@ -464,7 +467,7 @@ export function useGroupReportGeneration({
|
|||||||
const pushLog = (log: ReportGenerationLog): void => {
|
const pushLog = (log: ReportGenerationLog): void => {
|
||||||
context.logs.push(log)
|
context.logs.push(log)
|
||||||
setGenerationMetadata({
|
setGenerationMetadata({
|
||||||
modelName: selectedModel.model,
|
modelName: selectedModel.modelName || selectedModel.model,
|
||||||
generationLogs: [...context.logs]
|
generationLogs: [...context.logs]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -482,7 +485,7 @@ export function useGroupReportGeneration({
|
|||||||
setPhase('requestingModel')
|
setPhase('requestingModel')
|
||||||
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
|
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
|
||||||
setGenerationMetadata({
|
setGenerationMetadata({
|
||||||
modelName: selectedModel.model,
|
modelName: selectedModel.modelName || selectedModel.model,
|
||||||
generationLogs: [...context.logs]
|
generationLogs: [...context.logs]
|
||||||
})
|
})
|
||||||
writeReportLog('info', '调用模型生成日报内容', {
|
writeReportLog('info', '调用模型生成日报内容', {
|
||||||
@@ -619,7 +622,7 @@ export function useGroupReportGeneration({
|
|||||||
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
|
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
|
||||||
? exportFinishTime - context.startedAt
|
? exportFinishTime - context.startedAt
|
||||||
: Date.now() - context.startedAt,
|
: Date.now() - context.startedAt,
|
||||||
modelName: selectedModel.model,
|
modelName: selectedModel.modelName || selectedModel.model,
|
||||||
tokenUsage,
|
tokenUsage,
|
||||||
generationLogs: [...context.logs]
|
generationLogs: [...context.logs]
|
||||||
})
|
})
|
||||||
@@ -681,7 +684,10 @@ export function useGroupReportGeneration({
|
|||||||
const logs: ReportGenerationLog[] = []
|
const logs: ReportGenerationLog[] = []
|
||||||
const pushLog = (log: ReportGenerationLog): void => {
|
const pushLog = (log: ReportGenerationLog): void => {
|
||||||
logs.push(log)
|
logs.push(log)
|
||||||
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [...logs] })
|
setGenerationMetadata({
|
||||||
|
modelName: modelConfig.modelName || modelConfig.model,
|
||||||
|
generationLogs: [...logs]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
|
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
|
||||||
const startedAt = new Date()
|
const startedAt = new Date()
|
||||||
@@ -702,7 +708,10 @@ export function useGroupReportGeneration({
|
|||||||
setVoiceTranscriptionProgress(null)
|
setVoiceTranscriptionProgress(null)
|
||||||
setPreparationProgress(null)
|
setPreparationProgress(null)
|
||||||
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
|
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
|
||||||
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [] })
|
setGenerationMetadata({
|
||||||
|
modelName: modelConfig.modelName || modelConfig.model,
|
||||||
|
generationLogs: []
|
||||||
|
})
|
||||||
writeReportLog('info', '开始生成群聊日报', {
|
writeReportLog('info', '开始生成群聊日报', {
|
||||||
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||||
dateRange: summaryDateRange,
|
dateRange: summaryDateRange,
|
||||||
@@ -736,7 +745,8 @@ export function useGroupReportGeneration({
|
|||||||
memberNamePreference
|
memberNamePreference
|
||||||
)
|
)
|
||||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
|
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
|
||||||
onProgress: setPreparationProgress
|
onProgress: setPreparationProgress,
|
||||||
|
visionModel: visionModelConfig
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -782,7 +792,8 @@ export function useGroupReportGeneration({
|
|||||||
summaryDateRange,
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
templateId,
|
templateId,
|
||||||
transcribeSelectedVoiceMessages
|
transcribeSelectedVoiceMessages,
|
||||||
|
visionModelConfig
|
||||||
])
|
])
|
||||||
|
|
||||||
const retry = useCallback(
|
const retry = useCallback(
|
||||||
|
|||||||
@@ -467,12 +467,57 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-model-summary-content {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-model-selects {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-model-selects label {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-model-selects select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 30px 0 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-model-selects select:disabled {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.report-model-summary p {
|
.report-model-summary p {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
color: var(--wxex-text-secondary);
|
color: var(--wxex-text-secondary);
|
||||||
font: 13px/18px var(--wxex-font);
|
font: 13px/18px var(--wxex-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-model-summary small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
.report-model-summary button,
|
.report-model-summary button,
|
||||||
.report-result-actions button,
|
.report-result-actions button,
|
||||||
.report-task-error button {
|
.report-task-error button {
|
||||||
|
|||||||
@@ -245,19 +245,27 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 18px 22px 12px;
|
padding: 18px 22px 12px;
|
||||||
border-bottom: 1px solid var(--wxex-border);
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
background: var(--wxex-bg-main);
|
background: var(--wxex-bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-viewer-header > div:first-child {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.report-viewer-header h1 {
|
.report-viewer-header h1 {
|
||||||
font: 700 20px/27px var(--wxex-font);
|
font: 700 20px/27px var(--wxex-font);
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-viewer-toolbar {
|
.report-viewer-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -592,6 +600,15 @@
|
|||||||
.report-viewer-header {
|
.report-viewer-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-viewer-header > div:first-child {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
@@ -1169,3 +1186,198 @@
|
|||||||
color: var(--wxex-text-muted);
|
color: var(--wxex-text-muted);
|
||||||
font: 11px/17px var(--wxex-font);
|
font: 11px/17px var(--wxex-font);
|
||||||
}
|
}
|
||||||
|
.wechat-share-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(20, 31, 27, 0.46);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.wechat-share-dialog {
|
||||||
|
width: min(540px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid rgba(95, 129, 116, 0.2);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 28px 90px rgba(14, 32, 25, 0.24);
|
||||||
|
color: #17241f;
|
||||||
|
font-family: var(--wxex-font);
|
||||||
|
}
|
||||||
|
.wechat-share-dialog,
|
||||||
|
.wechat-share-dialog * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.wechat-share-dialog > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 26px 18px;
|
||||||
|
border-bottom: 1px solid #e8eeeb;
|
||||||
|
}
|
||||||
|
.wechat-share-dialog h2,
|
||||||
|
.wechat-share-dialog h3,
|
||||||
|
.wechat-share-dialog p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.wechat-share-dialog header h2 {
|
||||||
|
color: #17241f;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.wechat-share-dialog header p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #718078;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.wechat-share-dialog-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f3;
|
||||||
|
color: #5f6d67;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.wechat-share-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px 26px 26px;
|
||||||
|
}
|
||||||
|
.wechat-share-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.wechat-share-form label > span {
|
||||||
|
color: #33463e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.wechat-share-form input,
|
||||||
|
.wechat-share-form textarea {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #cfdbd6;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #fbfdfc;
|
||||||
|
padding: 11px 13px;
|
||||||
|
color: #1f2c27;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.wechat-share-form input:focus,
|
||||||
|
.wechat-share-form textarea:focus {
|
||||||
|
border-color: #32a978;
|
||||||
|
box-shadow: 0 0 0 3px rgba(50, 169, 120, 0.12);
|
||||||
|
}
|
||||||
|
.wechat-share-service-config {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #d6e8df;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f2faf6;
|
||||||
|
}
|
||||||
|
.wechat-share-service-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border: 1px solid #dce6e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7faf8;
|
||||||
|
}
|
||||||
|
.wechat-share-service-summary > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.wechat-share-service-summary span {
|
||||||
|
color: #728078;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.wechat-share-service-summary b {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #2c4138;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wechat-share-service-summary button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 11px;
|
||||||
|
}
|
||||||
|
.wechat-share-service-config h3 {
|
||||||
|
color: #23523f;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.wechat-share-service-config p,
|
||||||
|
.wechat-share-privacy {
|
||||||
|
color: #68776f;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.wechat-share-privacy {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f5f7f6;
|
||||||
|
}
|
||||||
|
.wechat-share-form > footer,
|
||||||
|
.wechat-share-success > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.wechat-share-form button,
|
||||||
|
.wechat-share-success button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 17px;
|
||||||
|
border: 1px solid #ced9d4;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #34463f;
|
||||||
|
}
|
||||||
|
.wechat-share-form button.primary,
|
||||||
|
.wechat-share-success button.primary {
|
||||||
|
border-color: #15945e;
|
||||||
|
background: #15945e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.wechat-share-form button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.wechat-share-success {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wechat-share-success > img {
|
||||||
|
width: 260px;
|
||||||
|
max-width: 80%;
|
||||||
|
border: 10px solid #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 35px rgba(27, 59, 46, 0.12);
|
||||||
|
}
|
||||||
|
.wechat-share-success h3 {
|
||||||
|
color: #203029;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.wechat-share-success p,
|
||||||
|
.wechat-share-success small {
|
||||||
|
color: #6c7b74;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.wechat-share-success > div {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
ImageCandidateQuery
|
ImageCandidateQuery
|
||||||
} from '../../../shared/image-insight'
|
} from '../../../shared/image-insight'
|
||||||
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
|
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
|
||||||
|
import type { ReportModelChoice } from '../../../shared/ai-provider'
|
||||||
|
|
||||||
interface ReportImageReadResult {
|
interface ReportImageReadResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -93,6 +94,7 @@ export interface ReportPreparationProgress {
|
|||||||
|
|
||||||
export interface BuildGroupReportFactsOptions {
|
export interface BuildGroupReportFactsOptions {
|
||||||
onProgress?: (progress: ReportPreparationProgress) => void
|
onProgress?: (progress: ReportPreparationProgress) => void
|
||||||
|
visionModel?: ReportModelChoice
|
||||||
}
|
}
|
||||||
|
|
||||||
function friendlyImageNotice(warnings: string[]): string {
|
function friendlyImageNotice(warnings: string[]): string {
|
||||||
@@ -422,6 +424,8 @@ const buildMediaSection = async (
|
|||||||
sender: candidate.sender,
|
sender: candidate.sender,
|
||||||
sentAt: candidate.sentAt,
|
sentAt: candidate.sentAt,
|
||||||
sessionId: candidate.sessionId,
|
sessionId: candidate.sessionId,
|
||||||
|
providerId: options.visionModel?.providerId,
|
||||||
|
modelId: options.visionModel?.model,
|
||||||
force: false
|
force: false
|
||||||
})
|
})
|
||||||
if (!analyzeResp.success || !analyzeResp.insight) {
|
if (!analyzeResp.success || !analyzeResp.insight) {
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export interface AIRuntimeModelConfig {
|
|||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 日报工作区内可选择的模型,不会修改全局默认 Provider。 */
|
||||||
|
export interface ReportModelChoice extends AIRuntimeModelConfig {
|
||||||
|
providerId: string
|
||||||
|
configured: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIVisionRuntimeConfig extends AIRuntimeModelConfig {
|
||||||
|
/** 图片理解模型独立于文字总结模型,由已验证的 vision/ocr capability 自动选择。 */
|
||||||
|
source: 'default-model' | 'vision-capability' | 'unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiSearchProviderStatus {
|
export interface AiSearchProviderStatus {
|
||||||
configured: boolean
|
configured: boolean
|
||||||
requiresConsent: boolean
|
requiresConsent: boolean
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ export type ImageCategory =
|
|||||||
|
|
||||||
export type ImageImportance = 'low' | 'medium' | 'high'
|
export type ImageImportance = 'low' | 'medium' | 'high'
|
||||||
|
|
||||||
|
/** 日报图片理解结果最多复用 10 分钟;旧记录保留在磁盘,仅不再作为缓存命中。 */
|
||||||
|
export const IMAGE_INSIGHT_CACHE_TTL_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
export const isFreshImageInsight = (
|
||||||
|
insight: Pick<ImageInsight, 'updatedAt'> | null | undefined,
|
||||||
|
now = Date.now()
|
||||||
|
): boolean =>
|
||||||
|
Boolean(
|
||||||
|
insight &&
|
||||||
|
Number.isFinite(insight.updatedAt) &&
|
||||||
|
insight.updatedAt > 0 &&
|
||||||
|
now >= insight.updatedAt &&
|
||||||
|
now - insight.updatedAt < IMAGE_INSIGHT_CACHE_TTL_MS
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单张微信图片的 AI 理解结果(持久化到 image-insights.json)
|
* 单张微信图片的 AI 理解结果(持久化到 image-insights.json)
|
||||||
*
|
*
|
||||||
@@ -57,6 +72,9 @@ export interface ImageAnalysisRequest {
|
|||||||
sender: string
|
sender: string
|
||||||
sentAt: number
|
sentAt: number
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
/** 日报局部选择的图片理解模型;未传时仍使用自动视觉路由。 */
|
||||||
|
providerId?: string
|
||||||
|
modelId?: string
|
||||||
/** 强制重新分析(忽略缓存) */
|
/** 强制重新分析(忽略缓存) */
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
@@ -118,8 +136,7 @@ export interface ImageCandidateQuery {
|
|||||||
export const isHotImageCandidate = (input: {
|
export const isHotImageCandidate = (input: {
|
||||||
responseCount: number
|
responseCount: number
|
||||||
interactionCount: number
|
interactionCount: number
|
||||||
}): boolean =>
|
}): boolean => input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
|
||||||
input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
|
|
||||||
|
|
||||||
export const calculateImageHeatScore = (input: {
|
export const calculateImageHeatScore = (input: {
|
||||||
responseCount: number
|
responseCount: number
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export interface GeneratedReportRecord {
|
|||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
duration?: number
|
duration?: number
|
||||||
|
/** 文字总结模型;modelName 保留为旧记录兼容字段。 */
|
||||||
|
textModelName?: string
|
||||||
|
/** 图片理解模型。 */
|
||||||
|
imageModelName?: string
|
||||||
modelName?: string
|
modelName?: string
|
||||||
tokenUsage?: {
|
tokenUsage?: {
|
||||||
input?: number
|
input?: number
|
||||||
@@ -62,6 +66,8 @@ export interface SaveGeneratedReportRequest {
|
|||||||
htmlPath?: string
|
htmlPath?: string
|
||||||
pngPath?: string
|
pngPath?: string
|
||||||
duration?: number
|
duration?: number
|
||||||
|
textModelName?: string
|
||||||
|
imageModelName?: string
|
||||||
modelName?: string
|
modelName?: string
|
||||||
tokenUsage?: {
|
tokenUsage?: {
|
||||||
input?: number
|
input?: number
|
||||||
|
|||||||
@@ -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 { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
|
||||||
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
|
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
|
||||||
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
|
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
|
||||||
|
import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel'
|
||||||
|
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
|
||||||
|
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
|
||||||
import type { Contact } from '../../src/shared/types'
|
import type { Contact } from '../../src/shared/types'
|
||||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||||
|
|
||||||
@@ -211,6 +214,61 @@ describe('daily report controls', () => {
|
|||||||
expect(screen.getByText(/从第三步继续/)).toBeVisible()
|
expect(screen.getByText(/从第三步继续/)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', () => {
|
||||||
|
const onTextModelChange = vi.fn()
|
||||||
|
const onVisionModelChange = vi.fn()
|
||||||
|
const textModels = [
|
||||||
|
{
|
||||||
|
providerId: 'deepseek',
|
||||||
|
providerName: 'DeepSeek',
|
||||||
|
model: 'deepseek-chat',
|
||||||
|
modelName: 'DeepSeek Chat',
|
||||||
|
configured: true as const,
|
||||||
|
status: 'connected' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: 'openai',
|
||||||
|
providerName: 'OpenAI',
|
||||||
|
model: 'gpt-5.6-sol',
|
||||||
|
modelName: 'GPT-5.6 Sol',
|
||||||
|
configured: true as const,
|
||||||
|
status: 'connected' as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const visionModels = [
|
||||||
|
{
|
||||||
|
providerId: 'sol-provider',
|
||||||
|
providerName: 'OpenAI',
|
||||||
|
model: 'gpt-5.6-sol',
|
||||||
|
modelName: 'GPT-5.6 Sol',
|
||||||
|
configured: true as const,
|
||||||
|
status: 'connected' as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
render(
|
||||||
|
<ModelSummary
|
||||||
|
config={textModels[0]}
|
||||||
|
visionConfig={visionModels[0]}
|
||||||
|
textModels={textModels}
|
||||||
|
visionModels={visionModels}
|
||||||
|
onTextModelChange={onTextModelChange}
|
||||||
|
onVisionModelChange={onVisionModelChange}
|
||||||
|
onOpenSettings={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const textSelect = screen.getByRole('combobox', { name: '文字总结模型' })
|
||||||
|
const visionSelect = screen.getByRole('combobox', { name: '图片理解模型' })
|
||||||
|
expect(textSelect).toHaveValue('deepseek::deepseek-chat')
|
||||||
|
expect(visionSelect).toHaveValue('sol-provider::gpt-5.6-sol')
|
||||||
|
expect(screen.getAllByRole('option', { name: 'OpenAI · GPT-5.6 Sol' })).toHaveLength(2)
|
||||||
|
fireEvent.change(textSelect, { target: { value: 'openai::gpt-5.6-sol' } })
|
||||||
|
fireEvent.change(visionSelect, { target: { value: 'sol-provider::gpt-5.6-sol' } })
|
||||||
|
expect(onTextModelChange).toHaveBeenCalledWith(textModels[1])
|
||||||
|
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[0])
|
||||||
|
expect(screen.getByText(/图片识别缓存 10 分钟/)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
it('zooms relative to a full-image fit constrained by viewport width and height', () => {
|
it('zooms relative to a full-image fit constrained by viewport width and height', () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver
|
const originalResizeObserver = globalThis.ResizeObserver
|
||||||
globalThis.ResizeObserver = class {
|
globalThis.ResizeObserver = class {
|
||||||
@@ -272,6 +330,116 @@ describe('daily report controls', () => {
|
|||||||
globalThis.ResizeObserver = originalResizeObserver
|
globalThis.ResizeObserver = originalResizeObserver
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps zoom working when a newly saved report replaces the initial result', () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe(): void {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
disconnect(): void {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
unobserve(): void {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const baseReport: GeneratedReportRecord = {
|
||||||
|
id: 'temporary-result',
|
||||||
|
contactId: 'group-md5',
|
||||||
|
contactName: '测试群',
|
||||||
|
dateRange: '今天',
|
||||||
|
messageCount: 10,
|
||||||
|
generatedAt: '2026-08-13T10:00:00.000Z',
|
||||||
|
reportDate: '2026-08-13',
|
||||||
|
htmlStatus: 'ready',
|
||||||
|
pngStatus: 'ready',
|
||||||
|
generatedImage: 'data:image/png;base64,fixture'
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
hasReports: true,
|
||||||
|
onBackToConfigure: vi.fn(),
|
||||||
|
onRegenerate: vi.fn(),
|
||||||
|
onCopyImage: vi.fn(async () => ({ success: true })),
|
||||||
|
onReveal: vi.fn(async () => ({ success: true })),
|
||||||
|
onSwitchTemplate: vi.fn(async () => ({ success: true }))
|
||||||
|
}
|
||||||
|
const { rerender } = render(<ReportViewer report={baseReport} {...props} />)
|
||||||
|
let image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
|
||||||
|
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
|
||||||
|
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
|
||||||
|
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
|
||||||
|
configurable: true,
|
||||||
|
value: 544
|
||||||
|
})
|
||||||
|
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
|
||||||
|
configurable: true,
|
||||||
|
value: 1044
|
||||||
|
})
|
||||||
|
fireEvent.load(image)
|
||||||
|
expect(image.style.width).toBe('500px')
|
||||||
|
|
||||||
|
rerender(<ReportViewer report={{ ...baseReport, id: 'saved-result' }} {...props} />)
|
||||||
|
image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
|
||||||
|
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
|
||||||
|
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
|
||||||
|
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
|
||||||
|
configurable: true,
|
||||||
|
value: 544
|
||||||
|
})
|
||||||
|
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
|
||||||
|
configurable: true,
|
||||||
|
value: 1044
|
||||||
|
})
|
||||||
|
fireEvent.load(image)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放大' }))
|
||||||
|
expect(image.style.width).toBe('625px')
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps secondary report actions inside More and labels both AI model roles', () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<ReportToolbar
|
||||||
|
canCopyImage
|
||||||
|
canReveal
|
||||||
|
canShare
|
||||||
|
canSwitchTemplate
|
||||||
|
currentTemplateId="v1"
|
||||||
|
isSwitchingTemplate={false}
|
||||||
|
onSwitchTemplate={vi.fn()}
|
||||||
|
onRegenerate={vi.fn()}
|
||||||
|
onCopyImage={vi.fn()}
|
||||||
|
onReveal={vi.fn()}
|
||||||
|
onShare={vi.fn()}
|
||||||
|
/>
|
||||||
|
<ReportInfoPanel
|
||||||
|
report={{
|
||||||
|
id: 'model-info',
|
||||||
|
contactId: 'group-md5',
|
||||||
|
contactName: '测试群',
|
||||||
|
dateRange: '今天',
|
||||||
|
messageCount: 10,
|
||||||
|
generatedAt: '2026-08-13T10:00:00.000Z',
|
||||||
|
reportDate: '2026-08-13',
|
||||||
|
htmlStatus: 'ready',
|
||||||
|
pngStatus: 'ready',
|
||||||
|
textModelName: 'deepseek-chat',
|
||||||
|
imageModelName: 'gpt-5.6-sol'
|
||||||
|
}}
|
||||||
|
onReveal={vi.fn(async () => ({ success: true }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '生成微信卡片' })).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '更多' }))
|
||||||
|
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||||
|
expect(screen.getByText('文字模型')).toBeVisible()
|
||||||
|
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
|
||||||
|
expect(screen.getByText('图片模型')).toBeVisible()
|
||||||
|
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
||||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||||
const report: GeneratedReportRecord = {
|
const report: GeneratedReportRecord = {
|
||||||
|
|||||||
+22
-1
@@ -273,6 +273,12 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
|||||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||||
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
||||||
|
const textModel = fixture.page.getByRole('combobox', { name: '文字总结模型' })
|
||||||
|
const visionModel = fixture.page.getByRole('combobox', { name: '图片理解模型' })
|
||||||
|
await expect(textModel).toHaveValue('fixture-provider::fixture-model')
|
||||||
|
await expect(visionModel).toHaveValue('fixture-provider::fixture-vision-model')
|
||||||
|
await expect(textModel.locator('option')).toHaveCount(2)
|
||||||
|
await expect(visionModel.locator('option')).toHaveCount(1)
|
||||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||||
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
|
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
|
||||||
@@ -281,6 +287,20 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
|||||||
await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({
|
await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({
|
||||||
timeout: 15_000
|
timeout: 15_000
|
||||||
})
|
})
|
||||||
|
await expect(fixture.page.getByText('文字模型')).toBeVisible()
|
||||||
|
await expect(fixture.page.getByText('固定响应模型')).toBeVisible()
|
||||||
|
await expect(fixture.page.getByText('图片模型')).toBeVisible()
|
||||||
|
await expect(fixture.page.getByText('固定图片识别模型')).toBeVisible()
|
||||||
|
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toHaveCount(0)
|
||||||
|
await fixture.page.getByRole('button', { name: '更多' }).click()
|
||||||
|
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||||
|
|
||||||
|
await fixture.page.setViewportSize({ width: 1024, height: 760 })
|
||||||
|
const reportTitle = fixture.page.getByRole('heading', { name: '产品测试群 群聊日报' })
|
||||||
|
await expect(reportTitle).toBeVisible()
|
||||||
|
expect((await reportTitle.boundingBox())?.width || 0).toBeGreaterThan(170)
|
||||||
|
await expect(fixture.page.getByRole('button', { name: '放大' })).toBeEnabled()
|
||||||
|
await fixture.page.getByRole('button', { name: '放大' }).click()
|
||||||
|
|
||||||
const exported = await fixture.page.evaluate(async () =>
|
const exported = await fixture.page.evaluate(async () =>
|
||||||
window.api.exportGroupReport({
|
window.api.exportGroupReport({
|
||||||
@@ -308,7 +328,8 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
|
|||||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||||
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
||||||
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
|
await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
|
||||||
|
await expect(fixture.page.getByText(/从第三步继续/)).toBeVisible()
|
||||||
await fixture.page.getByRole('button', { name: '档案' }).click()
|
await fixture.page.getByRole('button', { name: '档案' }).click()
|
||||||
await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
|
await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -411,9 +411,44 @@ handle('ai:getRuntimeConfig', () => ({
|
|||||||
status: 'connected',
|
status: 'connected',
|
||||||
timeoutMs: 5000
|
timeoutMs: 5000
|
||||||
}))
|
}))
|
||||||
|
handle('ai:getVisionRuntimeConfig', () => ({
|
||||||
|
providerId: 'fixture-vision-provider',
|
||||||
|
providerName: '本地图片假服务',
|
||||||
|
model: 'fixture-vision-model',
|
||||||
|
modelName: '固定图片识别模型',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
timeoutMs: 5000,
|
||||||
|
source: 'vision-capability'
|
||||||
|
}))
|
||||||
handle('ai:listProviders', () => ({
|
handle('ai:listProviders', () => ({
|
||||||
success: true,
|
success: true,
|
||||||
providers: [],
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'fixture-provider',
|
||||||
|
name: '本地假服务',
|
||||||
|
type: 'openai-compatible',
|
||||||
|
baseUrl: 'http://127.0.0.1:1/v1',
|
||||||
|
auth: { type: 'none' },
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'fixture-model',
|
||||||
|
name: '固定响应模型',
|
||||||
|
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fixture-vision-model',
|
||||||
|
name: '固定图片识别模型',
|
||||||
|
capabilities: { chat: true, vision: true, ocr: true, longContext: true }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultModel: 'fixture-model',
|
||||||
|
advanced: { timeoutMs: 5000, extraHeaders: {} },
|
||||||
|
hasApiKey: true,
|
||||||
|
isDefault: true,
|
||||||
|
status: 'connected'
|
||||||
|
}
|
||||||
|
],
|
||||||
defaultProviderId: 'fixture-provider'
|
defaultProviderId: 'fixture-provider'
|
||||||
}))
|
}))
|
||||||
handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
|
handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AIProviderConfig } from '../../src/shared/ai-provider'
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'tracememo-ai-vision-routing-'))
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: { getPath: () => root },
|
||||||
|
safeStorage: {
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString: (value: string) => Buffer.from(value),
|
||||||
|
decryptString: (value: Buffer) => value.toString('utf8')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { AIProviderService } from '../../src/main/services/ai-provider-service'
|
||||||
|
|
||||||
|
const provider = (id: string, modelId: string, vision: boolean): AIProviderConfig => ({
|
||||||
|
id,
|
||||||
|
name: id === 'deepseek' ? 'DeepSeek' : 'OpenAI',
|
||||||
|
type: 'openai-compatible',
|
||||||
|
baseUrl: `https://${id}.example.test/v1`,
|
||||||
|
auth: { type: 'none' },
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: modelId,
|
||||||
|
name: modelId,
|
||||||
|
capabilities: { chat: true, vision, ocr: vision, longContext: true }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultModel: modelId,
|
||||||
|
advanced: { timeoutMs: 120_000, extraHeaders: {} }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AI provider vision routing', () => {
|
||||||
|
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||||
|
|
||||||
|
it('keeps DeepSeek as the text model while routing images to a verified vision model', () => {
|
||||||
|
const service = new AIProviderService()
|
||||||
|
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
|
||||||
|
expect(service.save(provider('sol-provider', 'gpt-5.6-sol', true)).success).toBe(true)
|
||||||
|
expect(service.setDefault('deepseek').success).toBe(true)
|
||||||
|
|
||||||
|
expect(service.getRuntimeConfig()).toMatchObject({
|
||||||
|
providerId: 'deepseek',
|
||||||
|
model: 'deepseek-chat'
|
||||||
|
})
|
||||||
|
expect(service.getVisionRuntimeConfig()).toMatchObject({
|
||||||
|
providerId: 'sol-provider',
|
||||||
|
model: 'gpt-5.6-sol',
|
||||||
|
configured: true,
|
||||||
|
source: 'vision-capability'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports unavailable when no configured model has vision capability', () => {
|
||||||
|
const service = new AIProviderService()
|
||||||
|
for (const item of service.list().providers) service.delete(item.id)
|
||||||
|
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
|
||||||
|
|
||||||
|
expect(service.getVisionRuntimeConfig()).toMatchObject({
|
||||||
|
configured: false,
|
||||||
|
model: '',
|
||||||
|
source: 'unavailable'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -210,9 +210,24 @@ describe('group report parsing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const input = await buildGroupReportInput(messages, null, true, 'full', {
|
const input = await buildGroupReportInput(messages, null, true, 'full', {
|
||||||
onProgress: progress
|
onProgress: progress,
|
||||||
|
visionModel: {
|
||||||
|
providerId: 'selected-vision-provider',
|
||||||
|
providerName: '视觉服务',
|
||||||
|
model: 'selected-vision-model',
|
||||||
|
modelName: '视觉模型',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expect(window.api.imageAnalyze).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
providerId: 'selected-vision-provider',
|
||||||
|
modelId: 'selected-vision-model'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
expect(input.prompt).toContain('AI 图片识别摘要:')
|
expect(input.prompt).toContain('AI 图片识别摘要:')
|
||||||
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
|
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
|
||||||
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
|
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
IMAGE_INSIGHT_CACHE_TTL_MS,
|
||||||
|
isFreshImageInsight,
|
||||||
|
type ImageInsight
|
||||||
|
} from '../../src/shared/image-insight'
|
||||||
|
|
||||||
const { getByHash } = vi.hoisted(() => ({ getByHash: vi.fn() }))
|
const { getByHash, upsert } = vi.hoisted(() => ({
|
||||||
|
getByHash: vi.fn(),
|
||||||
|
upsert: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../../src/main/db/image-insights-store', () => ({
|
vi.mock('../../src/main/db/image-insights-store', () => ({
|
||||||
imageInsightsStore: {
|
imageInsightsStore: {
|
||||||
getByHash,
|
getByHash,
|
||||||
upsert: vi.fn(),
|
upsert,
|
||||||
listBySession: vi.fn()
|
listBySession: vi.fn()
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -19,7 +27,11 @@ const query = {
|
|||||||
limit: 3
|
limit: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = (id: string, responseCount: number, interactionCount: number): {
|
const input = (
|
||||||
|
id: string,
|
||||||
|
responseCount: number,
|
||||||
|
interactionCount: number
|
||||||
|
): {
|
||||||
messageId: string
|
messageId: string
|
||||||
md5: string
|
md5: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -41,6 +53,7 @@ describe('ImageInsightService hot image selection', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getByHash.mockReset()
|
getByHash.mockReset()
|
||||||
getByHash.mockReturnValue(null)
|
getByHash.mockReturnValue(null)
|
||||||
|
upsert.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns fewer than three images when only two pass the hot threshold', async () => {
|
it('returns fewer than three images when only two pass the hot threshold', async () => {
|
||||||
@@ -78,11 +91,207 @@ describe('ImageInsightService hot image selection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps a cached insight attached to an eligible candidate', async () => {
|
it('keeps a cached insight attached to an eligible candidate', async () => {
|
||||||
const cached = { imageHash: 'a'.repeat(32), description: '缓存识别结果' }
|
const cached = {
|
||||||
|
imageHash: 'a'.repeat(32),
|
||||||
|
description: '缓存识别结果',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
|
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
|
||||||
|
|
||||||
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
||||||
|
|
||||||
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
|
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not attach a cached insight once its 10-minute TTL has elapsed', async () => {
|
||||||
|
getByHash.mockReturnValue({
|
||||||
|
imageHash: 'a'.repeat(32),
|
||||||
|
description: '过期识别结果',
|
||||||
|
updatedAt: Date.now() - IMAGE_INSIGHT_CACHE_TTL_MS
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
||||||
|
|
||||||
|
expect(result[0]).not.toHaveProperty('insight')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ImageInsightService cache TTL and vision routing', () => {
|
||||||
|
const now = new Date('2026-08-12T10:00:00.000Z').getTime()
|
||||||
|
const cachedInsight = (updatedAt: number): ImageInsight => ({
|
||||||
|
id: 'cached-insight',
|
||||||
|
messageId: 'image-1',
|
||||||
|
imageHash: 'a'.repeat(32),
|
||||||
|
description: '缓存图片描述',
|
||||||
|
tags: ['缓存'],
|
||||||
|
category: 'screenshot',
|
||||||
|
importance: 'medium',
|
||||||
|
provider: 'vision-provider',
|
||||||
|
model: 'vision-model',
|
||||||
|
createdAt: updatedAt,
|
||||||
|
updatedAt,
|
||||||
|
sender: '成员一',
|
||||||
|
sentAt: now,
|
||||||
|
sessionId: 'group@chatroom'
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
getByHash.mockReset()
|
||||||
|
getByHash.mockReturnValue(null)
|
||||||
|
upsert.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats only results strictly newer than 10 minutes as fresh', () => {
|
||||||
|
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1), now)).toBe(true)
|
||||||
|
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS), now)).toBe(false)
|
||||||
|
expect(isFreshImageInsight(cachedInsight(0), now)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the independent vision runtime after an expired cache entry', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(now)
|
||||||
|
getByHash.mockReturnValue(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS))
|
||||||
|
const analyzeImage = vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
data: JSON.stringify({
|
||||||
|
description: '重新识别后的图片描述',
|
||||||
|
ocrText: '新的 OCR',
|
||||||
|
tags: ['更新', '截图'],
|
||||||
|
category: 'screenshot',
|
||||||
|
importance: 'high'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
const getVisionRuntimeConfig = vi.fn(() => ({
|
||||||
|
providerId: 'sol-provider',
|
||||||
|
providerName: 'OpenAI',
|
||||||
|
model: 'gpt-5.6-sol',
|
||||||
|
modelName: 'gpt-5.6-sol',
|
||||||
|
configured: true
|
||||||
|
}))
|
||||||
|
imageInsightService.bind({
|
||||||
|
providerService: {
|
||||||
|
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
|
||||||
|
getVisionRuntimeConfig,
|
||||||
|
analyzeImage
|
||||||
|
},
|
||||||
|
decryptService: {
|
||||||
|
findImageFile: () => null,
|
||||||
|
decryptImageToBase64: () => null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await imageInsightService.analyze({
|
||||||
|
imageHash: 'a'.repeat(32),
|
||||||
|
imageDataUrl: 'data:image/png;base64,fixture',
|
||||||
|
messageId: 'image-1',
|
||||||
|
sender: '成员一',
|
||||||
|
sentAt: now,
|
||||||
|
sessionId: 'group@chatroom'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: true, fromCache: false })
|
||||||
|
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
|
||||||
|
providerId: 'sol-provider',
|
||||||
|
modelId: 'gpt-5.6-sol'
|
||||||
|
})
|
||||||
|
expect(upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: '重新识别后的图片描述',
|
||||||
|
provider: 'sol-provider',
|
||||||
|
model: 'gpt-5.6-sol',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the report-selected vision model on a cache miss', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(now)
|
||||||
|
const analyzeImage = vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
data: JSON.stringify({
|
||||||
|
description: '指定模型识别结果',
|
||||||
|
tags: ['指定'],
|
||||||
|
category: 'screenshot',
|
||||||
|
importance: 'medium'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
const getVisionRuntimeConfig = vi.fn(() => ({
|
||||||
|
providerId: 'automatic-provider',
|
||||||
|
providerName: '自动模型',
|
||||||
|
model: 'automatic-vision',
|
||||||
|
modelName: '自动视觉模型',
|
||||||
|
configured: true
|
||||||
|
}))
|
||||||
|
imageInsightService.bind({
|
||||||
|
providerService: {
|
||||||
|
list: () => ({ providers: [], defaultProviderId: 'automatic-provider' }),
|
||||||
|
getVisionRuntimeConfig,
|
||||||
|
analyzeImage
|
||||||
|
},
|
||||||
|
decryptService: {
|
||||||
|
findImageFile: () => null,
|
||||||
|
decryptImageToBase64: () => null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await imageInsightService.analyze({
|
||||||
|
imageHash: 'b'.repeat(32),
|
||||||
|
imageDataUrl: 'data:image/png;base64,fixture',
|
||||||
|
messageId: 'image-selected',
|
||||||
|
sender: '成员二',
|
||||||
|
sentAt: now,
|
||||||
|
sessionId: 'group@chatroom',
|
||||||
|
providerId: 'selected-provider',
|
||||||
|
modelId: 'selected-vision'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: true, fromCache: false })
|
||||||
|
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
|
||||||
|
providerId: 'selected-provider',
|
||||||
|
modelId: 'selected-vision'
|
||||||
|
})
|
||||||
|
expect(upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ provider: 'selected-provider', model: 'selected-vision' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a fresh cached result without calling the vision model', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(now)
|
||||||
|
const cached = cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1)
|
||||||
|
getByHash.mockReturnValue(cached)
|
||||||
|
const analyzeImage = vi.fn()
|
||||||
|
imageInsightService.bind({
|
||||||
|
providerService: {
|
||||||
|
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
|
||||||
|
getVisionRuntimeConfig: () => ({
|
||||||
|
providerId: 'sol-provider',
|
||||||
|
providerName: 'OpenAI',
|
||||||
|
model: 'gpt-5.6-sol',
|
||||||
|
modelName: 'gpt-5.6-sol',
|
||||||
|
configured: true
|
||||||
|
}),
|
||||||
|
analyzeImage
|
||||||
|
},
|
||||||
|
decryptService: {
|
||||||
|
findImageFile: () => null,
|
||||||
|
decryptImageToBase64: () => null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await imageInsightService.analyze({
|
||||||
|
imageHash: cached.imageHash,
|
||||||
|
imageDataUrl: 'data:image/png;base64,fixture',
|
||||||
|
messageId: cached.messageId,
|
||||||
|
sender: cached.sender,
|
||||||
|
sentAt: cached.sentAt,
|
||||||
|
sessionId: cached.sessionId
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, insight: cached, fromCache: true })
|
||||||
|
expect(analyzeImage).not.toHaveBeenCalled()
|
||||||
|
expect(upsert).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user