Compare commits

...
Author SHA1 Message Date
电摇小子 a52da455c3 fix: 修复ai模型页面loop循环问题 (#16)
修改获取密钥文案
新增模型跳转等问题
2026-08-15 21:17:18 +08:00
电摇小子 1fc39678ff feat: 支持导出自定义目录并优化聊天记录读取
接入导出保存目录选择功能
修复导出路径摘要未同步更新
按时间范围查询聊天记录,避免全量扫描历史消息
增加跨分片消息查询支持验证
2026-08-15 02:44:07 +08:00
Wxw-Gu 900e4c504c fix: windows打包 2026-08-13 16:11:30 +08:00
Wxw-Gu 8ca8ad9dee chore: 提升版本 2026-08-13 15:09:47 +08:00
Wxw-Gu aed373db93 Merge branch 'nanin/develop' into develop 2026-08-13 15:08:42 +08:00
Wxw-Gu f120f2079c fix: 优化体验 2026-08-13 15:05:48 +08:00
Wxw-Gu 46c9b98d75 docs: 更新readme 2026-08-13 15:05:36 +08:00
Wxw-Gu e3b668314d docs: 修改 2026-08-13 11:04:20 +08:00
Wxw-Gu 4436d7c8ce feat: 新增实验性微信卡片分享与自动部署能力
补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权
增加自动部署 Skill 和配置引导文档
优化报告工具栏、微信卡片弹窗及窄屏响应式布局
补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
2026-08-13 10:48:07 +08:00
Nanin daeba3388d fix: 修复语音文件缺失误报
批量语音读取失败时,并行回退到兼容的单条读取接口,避免批量接口异常导致全部语音被误判为缺失。

增量合并恢复可播放语音或转写后,清理矛盾的媒体及转写错误状态,并补充批量回退与增量合并回归测试。
2026-08-12 22:30:11 +08:00
Nanin 8fac752e2b feat: 支持按导出媒体文件名精确搜索
离线 HTML 搜索支持使用不含扩展名的完整图片或视频哈希文件名进行精确匹配,并保持其他消息字段的模糊搜索行为。\n\n补充图片和视频命中、大小写兼容、哈希片段及带扩展名不命中的 DOM 回归测试。
2026-08-12 22:02:43 +08:00
Nanin 34b86af0be perf: 加速语音转写缓存命中
迁移旧版语音转写缓存,并将补迁失败降级为一次性失败状态。

按账号和消息标识优先命中兼容缓存,未命中时才读取音频并计算哈希;导出缓存命中后合并异步刷新知识索引。

补充迁移、缓存快速路径、批量语音读取和导出流程测试。
2026-08-12 20:01:40 +08:00
Wxw-Gu d439b4b749 feat: 增加日报模板 2026-08-12 16:10:22 +08:00
Wxw-Gu a80624d6ab chore: package 2026-08-12 10:06:50 +08:00
96 changed files with 10446 additions and 895 deletions
+8
View File
@@ -26,3 +26,11 @@ VITE_IMAGE_AES_KEY=
# Electron E2E test window close delay in milliseconds.
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
WXE_E2E_CLOSE_DELAY_MS=2000
# Experimental self-hosted WeChat share-card service
# Copy these placeholders to .env. Never commit real AppSecret or UPLOAD_TOKEN values.
WECHAT_SHARE_DOMAIN=share.example.com
WECHAT_SHARE_APP_ID=
WECHAT_SHARE_APP_SECRET=
# Leave empty to let docs/skill/setup-wechat-share-card/scripts/setup.sh generate one.
WECHAT_SHARE_UPLOAD_TOKEN=
+2
View File
@@ -12,6 +12,8 @@ test-results/
resources/connectors/wechat/
.omc
.codex/
services/share-card-worker/.wrangler/
services/share-card-worker/wrangler.local.jsonc
docs/design/
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md
+6
View File
@@ -1,2 +1,8 @@
shamefully-hoist=true
electron_mirror=https://npmmirror.com/mirrors/electron/
# Keep the Windows x64 sherpa-onnx optional runtime available when packaging
# Windows from macOS/Linux hosts.
supportedArchitectures.os[]=darwin
supportedArchitectures.os[]=win32
supportedArchitectures.cpu[]=arm64
supportedArchitectures.cpu[]=x64
+220 -77
View File
@@ -30,36 +30,91 @@
</p>
<p align="center">
<img src="./public/机器人.png" alt="TraceMemo 主界面" />
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" />
</p>
---
## TraceMemo(迹忆)是什么
TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。
TraceMemo(迹忆)是一款**本地优先、可追溯的 AI 微信知识与分析工作台**
TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
## 为什么叫 TraceMemo(迹忆)
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
“迹忆”可以理解为“留下痕迹的记忆”。TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
> **品牌说明**
>
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
TraceMemo 原名 **WechatExplorer**,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
你可以直接浏览聊天,也可以用自然语言提问:
> “上个月我们讨论过哪些发布问题?”
>
> “张三之前发过的项目地址在哪里?”
>
> “技术交流群今天有哪些结论和待办?”
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。
你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
TraceMemo 不提供任何微信聊天数据,也不鼓励收集、上传、出售、共享或未经授权处理他人的聊天记录。使用 TraceMemo 时,请确保你对所处理的数据具有合法的访问和使用权限,并自行承担相应的数据安全与合规责任。
---
## 为什么叫 TraceMemo(迹忆)
<details>
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
“迹忆”可以理解为“留下痕迹的记忆”。
TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
> **品牌说明**
>
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
</details>
---
## 项目缘起
<details>
TraceMemo 最早叫 **WechatExplorer**
**2025 年 12 月**,我做出了第一个版本。当时功能很简单:解析微信 3.0 的聊天记录,再用 AI 生成群聊日报。最初只是给自己用,想把散落在微信里的信息重新找出来,也方便看看群里每天聊了什么。
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
为了支持微信 4.x,我开始重新研究数据访问。这部分工作得到了 **WeFlow** 很大的帮助。TraceMemo 目前的微信 4.x 数据连接能力,参考并使用了 **WeFlow 历史版本中的相关实现和思路**,包括数据库密钥获取、图片解密等底层能力。
> **没有 WeFlow,就没有今天的 TraceMemo。**
WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继续做后面的事情:让聊天记录可以被搜索、理解和总结,也让 AI 给出的答案能够回到原始消息核对。
在此基础上,项目陆续加入了:
- 本地知识库
- 消息来源追溯
- 群聊日报
- 语音消息也参与知识库等问答
- 微信机器人
- Local HTTP API
- Reader Skill
- Agent 接入
- 多种聊天记录导出能力
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。
</details>
---
## 💬 交流与反馈
@@ -69,34 +124,50 @@ TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录
## 从你的任务开始
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
| ----------------------------------------- | ------------------------------------------------------- | ------------------------------------ |
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
| 把群聊日报生成微信分享卡片(实验性) | [微信分享卡片](./docs/deployment/experimental-wechat-share-card.md) | 自备 Cloudflare、域名和微信测试号 |
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
---
## 最核心的三个能力
### 浏览和搜索微信历史
### 生成群聊日报
- 浏览联系人、群聊、折叠群聊和公众号消息
- 查看文本、图片、视频、语音、文件、链接、引用、小程序等内容。
- 搜索会话或当前聊天中的关键词。
- 从 AI 结果跳回对应聊天位置。
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图
详细说明:[聊天档案与普通搜索](./docs/user-guide/chat-archive.md)
报告包含:
- 热点
- 重要消息
- 资源
- 问答
- 待办
- 未解决事项
- 活跃统计
- 图片精选
具体内容取决于消息、媒体是否可读以及模型能力。
详细说明:[生成群聊日报](./docs/user-guide/report.md)
</details>
### AI 帮你找回聊过的内容
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。
你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
<p align="center">
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
@@ -106,61 +177,94 @@ TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交
### 直接在微信里问你的历史聊天
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。例如,你可以直接给机器人发送“最近 5 个会话”“张三最近和我聊了什么”,或者让它生成指定群聊的总结图片。TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
例如,你可以直接给机器人发送:
- “最近 5 个会话”
- “张三最近和我聊了什么”
- “总结今天的技术交流群”
TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。
当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
---
## 其他能力
### 本地知识库
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
<details>
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。
它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
### 生成群聊日报
</details>
### 实验性:生成微信分享卡片
<details>
<summary>查看群聊日报示例、内容和导出方式</summary>
TraceMemo 可以把群聊日报长图上传到你自己部署的 Cloudflare Worker 和 R2,并生成可在微信中分享的临时网页、二维码及卡片信息。
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。报告可能包含热点、重要消息、资源、问答、待办、未解决事项、活跃统计和图片精选;具体内容取决于消息、媒体是否可读以及模型能力
该功能需要自备 Cloudflare 账号、域名和微信测试号,目前不属于开箱即用的稳定功能
<p align="center">
<img src="./public/report-template-1.png" alt="群聊日报示例" />
<img src="./public/微信卡片分享.png" alt="微信卡片分享效果示例" />
</p>
详细说明:[生成群聊日报](./docs/user-guide/report.md)
详细说明:[实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)
不熟悉命令行的用户,可以把[自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)直接交给 Codex 或 Claude Code。
</details>
### 转写微信语音
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
<details>
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。
转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
详细说明:[语音转文字](./docs/user-guide/voice.md)
### 防撤回
可选开启后,TraceMemo 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
详细说明:[防撤回](./docs/user-guide/recall-protection.md)
</details>
### 导出长期可用的聊天档案
支持 HTML、CSV、JSON 和 Markdown。HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
<details>
支持 HTML、CSV、JSON 和 Markdown。
HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
详细说明:[导出聊天](./docs/user-guide/export.md)
</details>
### 在外部 Agent 中查询微信历史
通过 Reader Skill 和本机 Local HTTP APICodex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。这和微信机器人是两条不同路径:微信机器人收到消息后在微信中回复;外部 Agent 则主动查询历史。
<details>
通过 Reader Skill 和本机 Local HTTP APICodex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。
这和微信机器人是两条不同路径:
- **微信机器人**:收到消息后在微信中回复。
- **外部 Agent**:主动查询历史。
安装和技术说明请看[Agent 接入概览](./docs/agent/overview.md)与[Local HTTP API](./docs/agent/api.md)。
</details>
---
## 它如何工作
```mermaid
@@ -188,6 +292,8 @@ flowchart LR
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
---
## 支持平台与安装包
| 平台 | 处理器架构 | Releases 安装包 |
@@ -195,7 +301,11 @@ flowchart LR
| Windows | x64 | `-setup.exe` |
| macOS | Apple SiliconM 系列、arm64 | `.dmg` |
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
当前版本不支持 Intel 芯片的 Mac。
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
---
## 快速开始
@@ -207,22 +317,32 @@ flowchart LR
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。
迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
---
## 配置 AI
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。
应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
---
## 文档
- [文档首页](./docs/README.md)
@@ -231,6 +351,8 @@ Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行
- [AI 查找聊天信息](./docs/user-guide/ai-search.md)
- [本地知识库](./docs/user-guide/knowledge.md)
- [群聊日报](./docs/user-guide/report.md)
- [实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)
- [微信分享卡片自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)
- [语音转文字](./docs/user-guide/voice.md)
- [导出聊天](./docs/user-guide/export.md)
- [防撤回](./docs/user-guide/recall-protection.md)
@@ -240,6 +362,8 @@ Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行
- [Local HTTP API](./docs/agent/api.md)
- [开发与测试](./docs/development/overview.md)
---
## 本地开发
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
@@ -261,43 +385,62 @@ pnpm test:e2e:build
完整说明:[开发、测试与构建](./docs/development/overview.md)
---
## 支持与反馈
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。提交 Issue 时请提供操作系统、微信版本、TraceMemo 版本、复现步骤和已遮挡敏感信息的截图。
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
提交 Issue 时请提供:
- 操作系统
- 微信版本
- TraceMemo 版本
- 复现步骤
- 已遮挡敏感信息的截图
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
---
## 许可说明
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `LICENSE` 文件;贡献、复制或再分发前,请先向维护者确认 TraceMemo 本身的许可范围
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
---
## 致谢
<details>
<summary>展开致谢与参考项目</summary>
TraceMemo 的诞生离不开开源社区中许多优秀项目的工作。
TraceMemo 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
### 特别感谢 WeFlow
特别感谢:
TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库密钥获取、图片解密等底层能力。
特别感谢作者 **hicccc77** 的理解和包容。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
### 其他参考项目
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
- 提供了微信数据库解析相关思路。
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
- 参考了数据库密钥获取、图片解密等实现思路。
- 提供了数据库解析相关思路。
- **[chatlog](https://github.com/sjzar/chatlog)**
- 提供了聊天记录导出与数据处理方面的参考。
- 提供了数据处理方面的参考。
在此基础上,TraceMemo 进行了重新设计与实现,包括:
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
- AI 问问微信
- AI 群聊日报
- 本地 HTTP API
- Reader Skill
- Agent Hub
- 新手引导
- Electron + React 全新界面
- 本地优先 AI 工作流
---
感谢所有开源作者。
</details>
<p align="center">
<b>TraceMemo(迹忆)</b>
<br />
把微信聊过的事,找回来、问清楚、留下来。
</p>
+2
View File
@@ -12,6 +12,8 @@ TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块
- [建立本地知识库](./user-guide/knowledge.md)
- [生成群聊日报和总结](./user-guide/report.md)
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
- [语音转文字](./user-guide/voice.md)
- [导出聊天档案](./user-guide/export.md)
- [防撤回](./user-guide/recall-protection.md)
@@ -0,0 +1,447 @@
# 实验性功能:自托管微信分享卡片
![微信卡片分享效果示例](../../public/微信卡片分享.png)
> **实验性功能**
> 该能力需要用户自行准备 Cloudflare、域名和微信公众平台测试号,目前不属于开箱即用的稳定功能。Cloudflare、微信 JS-SDK、测试号权限或微信客户端行为变化,都可能导致分享卡片失效。
## 新手推荐:直接交给 Agent
如果你不熟悉 Cloudflare、Wrangler 或命令行,不需要手动照着整篇文档操作。把下面这个 Skill 文件夹交给 Codex、Claude Code 或其他能够操作项目终端的编程 Agent:
```text
docs/skill/setup-wechat-share-card/
```
然后对 Agent 说:
```text
请使用 setup-wechat-share-card Skill,帮我部署 TraceMemo 的实验性微信分享卡片服务。尽量自动完成,只在缺少必要信息时一次性问我。
```
Agent 会自动:
- 检查 Node.js、pnpm 和 Wrangler
- 必要时临时下载 Wrangler
- 打开 Cloudflare 登录并执行 `whoami`
- 自动生成 `UPLOAD_TOKEN`
- 创建或复用私有 R2 Bucket
- 写入 Worker Secret
- 根据你的域名生成本机 Worker 配置;
- 部署 Worker并执行健康检查和微信签名检查;
- 把上传密钥复制到剪贴板,供你粘贴到 TraceMemo。
Agent 无法替你创建微信测试号或决定使用哪个域名,因此通常只需要你提供:
1. 你准备使用的分享域名,例如 `share.example.com`
2. 微信测试号页面中的 AppID
3. 微信测试号页面中的 AppSecret;
4. 浏览器弹出 Cloudflare OAuth 页面时完成一次登录授权。
真实配置保存在被 Git 忽略的本机 `.env` 中,不会写入 `.env.example`。不要把 `.env` 发给别人或提交到仓库。
TraceMemo 可以把已经生成的群聊日报长图发布为一个临时网页,并在微信中分享成带有标题、描述和缩略图的卡片。
TraceMemo **不提供公共卡片服务器**。使用该功能前,需要按照本文部署一套属于你自己的卡片服务。日报图片将上传到你自己的 Cloudflare R2,而不是上传到 TraceMemo 作者的服务器。
## 这个功能解决什么问题
直接把日报 PNG 发到微信,只会显示为一张普通图片。微信卡片还需要:
- 一个域名 (未能备案的话 在微信里点击多次 可能会被微信内置窗口提示需要备案);
- 卡片标题和描述;
- 一张微信可以读取的缩略图;
- 微信 JS-SDK 签名;
- 一个临时保存日报图片的位置。
本项目提供的 Cloudflare Worker 负责这些工作。桌面端上传日报后,会得到一个分享链接和二维码。用微信扫码打开链接,再点击右上角菜单分享,即可生成微信卡片。
## 数据会经过哪里
```mermaid
flowchart LR
A[TraceMemo 本机日报 PNG] -->|带 UPLOAD_TOKEN 上传| B[你的 Cloudflare Worker]
B --> C[你的私有 R2 Bucket]
B -->|AppID + AppSecret| D[微信公众平台接口]
D -->|access_token 与 jsapi_ticket| B
B --> E[临时分享网页]
E --> F[微信 JS-SDK]
F --> G[微信好友或群聊卡片]
```
与 TraceMemo 的本地浏览能力不同,启用分享卡片后,当前日报长图、缩略图、卡片标题和描述会离开本机,上传到你控制的 Cloudflare 账号。
## 你需要准备什么
| 项目 | 用途 | 从哪里获得 |
| ------------------------ | -------------------------------------------- | ---------------------------------------- |
| Cloudflare 账号 | 运行 Worker 和保存 R2 图片 | 自行注册 Cloudflare |
| 托管在 Cloudflare 的域名 | 提供 HTTPS 分享地址 | 使用自己的域名,例如 `share.example.com` |
| R2 Bucket | 临时保存日报和缩略图 | 使用 Wrangler 创建 |
| `UPLOAD_TOKEN` | 阻止陌生人调用你的上传接口 | **由你自己随机生成** |
| 微信测试号 AppID | 标识调用 JS-SDK 的微信应用 | 微信公众平台接口测试号页面 |
| 微信测试号 AppSecret | Worker 获取微信接口凭据 | 微信公众平台接口测试号页面 |
| JS 接口安全域名 | 告诉微信哪些网页可以使用该 AppID 调用 JS-SDK | 在微信测试号页面填写你的分享域名 |
微信公众平台接口测试号入口:
<https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index>
微信 JS-SDK 官方文档:
<https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html>
## 理解三个重要配置
### `UPLOAD_TOKEN` 从哪里来
`UPLOAD_TOKEN` **不是从 Cloudflare 或微信后台领取的**,它是卡片服务部署者自己生成的一段随机密码。
它用于保护 Worker 的上传接口:TraceMemo 上传日报时,会发送:
```http
Authorization: Bearer <UPLOAD_TOKEN>
```
Worker 只有在密钥完全一致时才接受上传。没有它,任何知道接口地址的人都可能向你的 R2 上传文件并消耗资源。
在 macOS 或 Linux 中生成一枚 64 位十六进制随机密钥:
```bash
openssl rand -hex 32
```
示例输出只用于说明格式,不要直接使用:
```text
8a4d...一共 64 个十六进制字符...72ef
```
生成后,同一个值需要配置到两个地方:
1. Cloudflare Worker Secret `UPLOAD_TOKEN`
2. TraceMemo“生成微信卡片”弹窗中的“上传密钥”。
如果两边不一致,卡片服务会返回 HTTP 401 或“未授权”。
TraceMemo 会使用 Electron `safeStorage` 将服务地址和上传密钥加密保存在本机。不要把密钥提交到 Git,也不要写入 `wrangler.jsonc`
### AppID 和 AppSecret 从哪里来
打开[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index),使用微信扫码登录。
页面上方会显示:
- `appID`
- `appsecret`
将它们分别保存为 Worker Secret
```text
WECHAT_APP_ID
WECHAT_APP_SECRET
```
它们的作用不同:
- AppID 用于标识这个微信测试应用;
- AppSecret 是高敏感凭据,Worker 用它向微信服务器获取 `access_token`
- Worker 再使用 `access_token` 获取 `jsapi_ticket`
- 最后使用 `jsapi_ticket`、当前网页 URL、时间戳和随机串生成 JS-SDK 签名。
AppSecret 只能保存在 Worker Secret 中。不要把它填写到 TraceMemo 的“上传密钥”输入框,不要发送给前端,也不要提交到仓库。怀疑泄露时,应立即在微信后台重置并更新 Worker Secret。
### JS 接口安全域名是干什么的
JS 接口安全域名是微信对网页来源的白名单。
假设你的分享服务地址是:
```text
https://share.example.com
```
那么测试号页面中的“JS 接口安全域名”应填写:
```text
share.example.com
```
填写时:
- 不带 `https://`
- 不带 `/s/xxx` 等路径;
- 不要填写 Cloudflare Worker 名称;
- 必须与用户实际打开分享页时的域名一致。
它不是用来解析 DNS 的。域名仍然需要先在 Cloudflare 中正确绑定到 Worker。安全域名的作用是告诉微信:允许这个域名下的网页使用当前 AppID 请求 JS-SDK 能力。
如果没有配置、填错域名,或签名 URL 与实际页面 URL 不一致,通常会出现 `invalid signature``config:fail` 或分享信息没有生效。
微信可能要求下载一个 TXT 验证文件,并确保它可以通过下面的地址访问:
```text
https://share.example.com/微信提供的文件名.txt
```
项目 Worker 已包含根路径验证文件的实现方式。你需要把自己的文件名和内容加入 `services/share-card-worker/src/index.js` 中的 `WECHAT_DOMAIN_VERIFICATION`,然后重新部署。
## 自托管部署步骤
以下命令均在项目根目录执行。
### 1. 登录 Cloudflare
项目建议使用本地 Wrangler
```bash
pnpm exec wrangler login
pnpm exec wrangler whoami
```
如果本地版本的 OAuth 登录出现 `invalid_scope` 等问题,可临时使用更新版本:
```bash
pnpm dlx wrangler@latest login
pnpm dlx wrangler@latest whoami
```
登录注意事项:
- 让 Wrangler 自动打开浏览器最稳妥;
- 不要复用以前生成的 OAuth 链接;
- 不要修改链接中的 `state``code_challenge` 或回调地址;
- 不建议使用无痕窗口或跨浏览器复制链接;
- 默认回调使用 `localhost:8976`,端口被占用时先结束旧的 Wrangler 登录进程;
- 浏览器提示授权成功后,仍应通过 `whoami` 核对账号。
### 2. 修改 Worker 配置
打开:
```text
services/share-card-worker/wrangler.jsonc
```
至少修改下面两个位置:
```jsonc
{
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
```
`routes[].pattern` 是 Worker 自定义域名,`PUBLIC_ORIGIN` 是生成分享链接和校验签名来源时使用的完整 HTTPS 地址,两者必须一致。
不要直接照抄仓库维护者的域名。请替换为你自己 Cloudflare 账号中的域名或子域名。
### 3. 创建私有 R2 Bucket
默认配置使用 Bucket 名称:
```text
wechatexplorer-share-reports
```
创建:
```bash
pnpm exec wrangler r2 bucket create wechatexplorer-share-reports \
--config services/share-card-worker/wrangler.jsonc
```
Worker 中的绑定名称是 `REPORTS`。R2 会保存:
```text
cards/<card-id>/card.json
cards/<card-id>/report.png
cards/<card-id>/thumbnail.jpg
```
- `card.json`:标题、描述、创建时间和过期时间;
- `report.png`:完整日报长图;
- `thumbnail.jpg`:微信卡片缩略图。
请保持 R2 Bucket 私有,不要启用公开 `r2.dev` 开发 URL。图片应统一通过 Worker 的随机卡片 URL 读取。
### 4. 生成并配置 `UPLOAD_TOKEN`
```bash
openssl rand -hex 32
```
复制生成结果,然后执行:
```bash
pnpm exec wrangler secret put UPLOAD_TOKEN \
--config services/share-card-worker/wrangler.jsonc
```
Wrangler 提示输入时粘贴密钥。终端不会正常显示 Secret 内容。
### 5. 配置微信 AppID 和 AppSecret
从[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)复制 AppID
```bash
pnpm exec wrangler secret put WECHAT_APP_ID \
--config services/share-card-worker/wrangler.jsonc
```
再复制 AppSecret
```bash
pnpm exec wrangler secret put WECHAT_APP_SECRET \
--config services/share-card-worker/wrangler.jsonc
```
Secret 不会出现在 `wrangler.jsonc` 中。如果你更换 Cloudflare 账号或重新创建 Worker,需要重新配置全部三个 Secret。
### 6. 配置微信测试号
在测试号页面完成:
1. 使用测试微信关注该测试号;
2.`share.example.com` 填入“JS 接口安全域名”;
3. 按页面提示完成 TXT 文件域名验证;
4. 确认 AppID/AppSecret 与刚才写入 Worker 的值来自同一个测试号。
测试号只适合开发和验证。正式公众号的接口权限、认证要求和后台菜单可能不同,请以微信公众平台实际规则为准。
### 7. 部署 Worker
```bash
pnpm exec wrangler deploy \
--config services/share-card-worker/wrangler.jsonc
```
Cloudflare Custom Domain 要求域名已经位于同一 Cloudflare 账号中。如果该子域名已经存在 A、AAAA 或 CNAME 记录,绑定可能失败。删除冲突记录,或者换一个未使用的子域名,例如 `share2.example.com`
更新 Secret 后,如果线上仍提示旧配置,可再执行一次完整部署。
## 在 TraceMemo 中配置
生成一份日报后,点击“生成微信卡片(实验性)”。首次使用需要填写:
```text
服务地址:https://share.example.com
上传密钥:你自己通过 openssl rand -hex 32 生成的 UPLOAD_TOKEN
```
这里的“上传密钥”绝对不是微信 AppSecret。
配置保存后,TraceMemo 会上传当前日报和缩略图,返回二维码。使用已经关注测试号的微信扫码,打开页面后再通过右上角菜单分享。
## 验证部署
### 健康检查
```bash
curl -fsS https://share.example.com/health
```
正常结果类似:
```json
{ "ok": true, "service": "wechatexplorer-share-card", "storage": "ready" }
```
### JS-SDK 签名检查
```bash
curl -fsS \
'https://share.example.com/api/wx-signature?url=https%3A%2F%2Fshare.example.com%2Fhealth'
```
正常结果应包含:
```text
appId
timestamp
nonceStr
signature
```
响应中不应包含 AppSecret、`access_token``jsapi_ticket`
## 常见问题
### HTTP 401 / 未授权
TraceMemo 中保存的上传密钥与 Worker 的 `UPLOAD_TOKEN` 不一致。重新生成或重新配置时,必须同步更新两边。
### “微信 JS-SDK 尚未配置”
Worker 缺少 `WECHAT_APP_ID``WECHAT_APP_SECRET`。执行两个 `secret put`,再重新部署。
### `invalid signature` 或分享信息不生效
依次检查:
- `PUBLIC_ORIGIN` 是否与浏览器实际访问的 origin 完全一致;
- JS 接口安全域名是否只填写了域名;
- AppID/AppSecret 是否属于同一个测试号;
- AppSecret 是否已被重置但 Worker 仍保存旧值;
- 分享页面是否经过了改变 URL 的代理或重定向;
- 测试微信是否已关注测试号。
### 自定义域名绑定失败
检查同名 A、AAAA、CNAME 记录是否已经存在,域名是否位于当前 Wrangler 登录的 Cloudflare 账号中。
### R2 未配置
确认 Bucket 存在,并且 `wrangler.jsonc` 中的绑定名称为 `REPORTS``bucket_name` 与实际 Bucket 一致。
### 卡片过期或图片消失
默认有效期为 7 天。Worker 的定时任务会删除过期卡片的元数据、日报和缩略图,这是设计行为。
## 安全和隐私注意事项
- 日报可能包含敏感群聊内容。只分享你有权分享的内容。
- 获得分享 URL 的人,在过期前可能查看对应日报;当前实现不是按访问者身份授权。
- `UPLOAD_TOKEN` 是整个 Worker 的服务级密钥,不是每个用户独立的账号凭据。
- 不要把 `UPLOAD_TOKEN`、AppSecret 或 Wrangler 凭据提交到 Git。
- R2 保持私有,不要把 Bucket 直接公开。
- 建议定期轮换 `UPLOAD_TOKEN`,怀疑泄露时立即轮换。
- 微信 AppSecret 泄露时,应在微信后台重置,并立即更新 Worker Secret。
- 自托管者自行承担 Cloudflare 用量、域名、数据合规和微信平台规则相关责任。
## 当前实验性限制
- 需要用户自己部署,普通用户无法直接开箱使用;
- 使用一个共享的 `UPLOAD_TOKEN`,没有多用户账号系统;
- 分享链接在有效期内属于“知道链接即可访问”;
- 依赖微信 JS-SDK 和测试号能力,微信侧规则变化可能造成失效;
- 当前仅上传 PNG 日报和 JPEG 缩略图;
- 没有管理后台用于列出、提前删除或审计所有卡片;
- 过期清理由定时任务完成,不保证到期瞬间立即删除。
## 代码入口
- Worker`services/share-card-worker/src/index.js`
- Worker 配置:`services/share-card-worker/wrangler.jsonc`
- Worker 测试:`services/share-card-worker/test/index.test.js`
- 桌面端上传:`src/main/wechat-share-card-service.ts`
- 本地加密配置:`src/main/wechat-share-config-store.ts`
- 分享弹窗:`src/renderer/src/components/reports/WechatShareCardDialog.tsx`
- 共享类型:`src/shared/wechat-share-card.ts`
## 参考资料
- [微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)
- [微信 JS-SDK 官方文档](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html)
- [Cloudflare Wrangler 命令文档](https://developers.cloudflare.com/workers/wrangler/commands/)
- [Cloudflare R2 Wrangler 命令](https://developers.cloudflare.com/workers/wrangler/commands/r2/)
- [在 Worker 中绑定和使用 R2](https://developers.cloudflare.com/r2/api/workers/workers-api-usage/)
- [Cloudflare Worker Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
@@ -0,0 +1,92 @@
---
name: setup-wechat-share-card
description: 自动配置和部署 TraceMemo 实验性微信分享卡片服务。用户要求启用、部署、修复或迁移微信分享卡片,配置 Cloudflare Worker/R2/Wrangler,设置 UPLOAD_TOKEN、微信测试号 AppID/AppSecret、JS 接口安全域名,或希望由 Codex、Claude Code 等 Agent 代替手工阅读部署文档时使用。
---
# 部署微信分享卡片
尽量自行发现项目状态并完成部署。只在自动检查后仍缺少必要信息时,一次性询问用户;不要逐项反复确认。
## 安全边界
- 不把真实 AppSecret、`UPLOAD_TOKEN`、Cloudflare Token 或微信验证内容写入 Git。
- `.env.example` 只保存占位符;真实值写入项目根目录 `.env`,该文件必须被 Git 忽略。
- 不在最终回答中回显 Secret。日志中只报告“已配置/缺失”。
- `UPLOAD_TOKEN` 默认自动生成,不要求用户提供。
- AppID/AppSecret 必须来自用户自己的微信测试号或公众号。无法自动获取时才询问。
- 部署、创建 R2 和写 Secret 属于用户明确请求本 Skill 后的正常动作;不要提交、推送或创建 PR,除非用户另外明确要求。
## 自动工作流
1. 定位 TraceMemo 仓库根目录。确认存在 `services/share-card-worker/wrangler.jsonc`
2. 运行:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh doctor
```
3. 检查根目录 `.env`。脚本会自动复用已有配置并生成缺失的 `WECHAT_SHARE_UPLOAD_TOKEN`。
4. 如果以下值缺失,只向用户发起一次集中询问:
- 分享域名,例如 `share.example.com`
- 微信测试号 AppID
- 微信测试号 AppSecret。
5. 用户不知道从哪里获取时,告诉他打开:
```text
https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index
```
使用微信扫码登录后,复制页面上的 `appID` 和 `appsecret`。提醒用户把分享域名填入“JS 接口安全域名”,不带 `https://` 和路径。
6. 将缺失值交给交互脚本,不要把 Secret 放进命令行参数:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh configure
```
该命令通过终端交互收集缺项,AppSecret 使用隐藏输入。
7. 执行完整部署:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh deploy
```
脚本会依次:
- 检查或临时下载 Wrangler
- 启动 Cloudflare OAuth 登录;
- 执行 `whoami`
- 生成本地 `wrangler.local.jsonc`
- 创建或复用 R2 Bucket
- 写入三个 Worker Secret
- 部署 Worker
- 检查 `/health` 和微信签名接口。
8. OAuth 页面出现时,让用户只完成浏览器登录/授权;不要改用 API Token,除非用户主动要求。
9. 部署后把服务地址告诉用户,并提醒他在 TraceMemo 卡片弹窗粘贴 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN`。优先把 Token 复制到剪贴板,不在聊天中展示:
```bash
bash docs/skill/setup-wechat-share-card/scripts/setup.sh copy-token
```
10. 如果微信要求 TXT 验证文件,读取 [references/wechat-domain-verification.md](references/wechat-domain-verification.md),取得用户提供的文件后再修改 Worker。
## 决策规则
- Wrangler 未安装:优先使用项目依赖;否则通过 `pnpm dlx wrangler@latest` 临时下载,不强制全局安装。
- `whoami` 已登录正确账号:不要重复登录。
- R2 已存在:继续,不把“已存在”视为失败。
- 自定义域名有 A/AAAA/CNAME 冲突:报告准确域名并要求用户选择删除冲突记录或换子域名;不要擅自删除 DNS。
- HTTP 401:重新同步 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN` 到 Worker,再让用户更新 TraceMemo。
- “微信 JS-SDK 尚未配置”:重新写入 AppID/AppSecret 并部署。
- 微信返回 AppID/AppSecret 错误:让用户检查是否来自同一个测试号、AppSecret 是否已重置。
- 缺少 JS 接口安全域名或测试号关注:这是微信后台操作,明确告诉用户要填写什么,不要假装已完成。
## 验证结果
完成前必须确认:
- `wrangler whoami` 成功;
- Worker 部署成功;
- `/health` 返回 `storage: ready`
- `/api/wx-signature` 返回 `appId`、`timestamp`、`nonceStr`、`signature`
- Git 扫描未发现 `.env`、真实 AppSecret、上传密钥或用户域名被暂存。
详细产品和架构说明见:`docs/deployment/experimental-wechat-share-card.md`。
@@ -0,0 +1,4 @@
interface:
display_name: "部署微信分享卡片"
short_description: "让 Agent 自动完成微信分享卡片服务的配置与部署"
default_prompt: "Use $setup-wechat-share-card to configure and deploy my self-hosted WeChat share-card service with minimal questions."
@@ -0,0 +1,15 @@
# 微信域名验证文件
当微信测试号页面要求下载 TXT 文件时:
1. 向用户索取 TXT 文件本身,或文件名与完整内容。
2. 不把真实验证内容写进公开仓库历史。
3. 优先在本地私有配置中注入;若当前 Worker 只能通过源码 Map 返回,则先提醒用户该值会进入工作区,确认仓库发布前必须移除或改造成 Secret/变量。
4. 验证目标必须是:
```text
https://<分享域名>/<微信提供的文件名>.txt
```
5. 返回内容必须是纯文本且与微信提供内容完全一致,不加空格、HTML 或额外换行。
6. 完成验证后再配置 JS 接口安全域名。安全域名只填主机名,不带协议或路径。
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
ENV_FILE="$REPO_ROOT/.env"
EXAMPLE_FILE="$REPO_ROOT/.env.example"
WORKER_DIR="$REPO_ROOT/services/share-card-worker"
BASE_CONFIG="$WORKER_DIR/wrangler.jsonc"
LOCAL_CONFIG="$WORKER_DIR/wrangler.local.jsonc"
BUCKET_NAME="wechatexplorer-share-reports"
cd "$REPO_ROOT"
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
info() {
printf '[share-card] %s\n' "$*"
}
require_project() {
[[ -f "$BASE_CONFIG" ]] || fail "请在 TraceMemo 仓库根目录运行此脚本"
[[ -f "$EXAMPLE_FILE" ]] || fail "缺少 .env.example"
}
ensure_env_file() {
if [[ ! -f "$ENV_FILE" ]]; then
cp "$EXAMPLE_FILE" "$ENV_FILE"
chmod 600 "$ENV_FILE"
info "已从 .env.example 创建本机 .env"
fi
}
read_env_value() {
local key="$1"
local line
line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
printf '%s' "${line#*=}"
}
write_env_value() {
local key="$1"
local value="$2"
local escaped
escaped="$(printf '%s' "$value" | sed 's/[\\&|]/\\&/g')"
if grep -q -E "^${key}=" "$ENV_FILE"; then
sed -i.bak -E "s|^${key}=.*$|${key}=${escaped}|" "$ENV_FILE"
command rm "$ENV_FILE.bak"
else
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
}
normalize_domain() {
local value="$1"
value="${value#http://}"
value="${value#https://}"
value="${value%%/*}"
printf '%s' "$value"
}
ensure_upload_token() {
local token
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
if [[ ${#token} -lt 32 ]]; then
token="$(openssl rand -hex 32)"
write_env_value WECHAT_SHARE_UPLOAD_TOKEN "$token"
info "已生成新的 UPLOAD_TOKEN 并安全写入 .env"
fi
}
wrangler() {
if [[ -x "$REPO_ROOT/node_modules/.bin/wrangler" ]]; then
"$REPO_ROOT/node_modules/.bin/wrangler" "$@"
elif command -v pnpm >/dev/null 2>&1; then
pnpm dlx wrangler@latest "$@"
elif command -v npx >/dev/null 2>&1; then
npx --yes wrangler@latest "$@"
else
fail "需要 Node.js 以及 pnpm 或 npm 才能运行 Wrangler"
fi
}
ensure_wrangler_login() {
if wrangler whoami >/dev/null 2>&1; then
wrangler whoami
return
fi
info "即将打开 Cloudflare OAuth 登录,请在浏览器中完成授权"
wrangler login
wrangler whoami
}
validate_required_config() {
local domain app_id app_secret token
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
[[ -n "$domain" && "$domain" != "share.example.com" ]] || fail "缺少真实 WECHAT_SHARE_DOMAIN"
[[ -n "$app_id" ]] || fail "缺少 WECHAT_SHARE_APP_ID"
[[ -n "$app_secret" ]] || fail "缺少 WECHAT_SHARE_APP_SECRET"
[[ ${#token} -ge 32 ]] || fail "WECHAT_SHARE_UPLOAD_TOKEN 长度不足"
}
configure_interactively() {
ensure_env_file
local domain app_id app_secret
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
if [[ -z "$domain" || "$domain" == "share.example.com" ]]; then
read -r -p '分享域名(例如 share.example.com,不带 https://):' domain
domain="$(normalize_domain "$domain")"
[[ -n "$domain" ]] || fail "分享域名不能为空"
write_env_value WECHAT_SHARE_DOMAIN "$domain"
fi
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
if [[ -z "$app_id" ]]; then
read -r -p '微信测试号 AppID' app_id
[[ -n "$app_id" ]] || fail "AppID 不能为空"
write_env_value WECHAT_SHARE_APP_ID "$app_id"
fi
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
if [[ -z "$app_secret" ]]; then
read -r -s -p '微信测试号 AppSecret(输入不会显示):' app_secret
printf '\n'
[[ -n "$app_secret" ]] || fail "AppSecret 不能为空"
write_env_value WECHAT_SHARE_APP_SECRET "$app_secret"
fi
ensure_upload_token
info "本机配置已准备完成"
}
generate_local_config() {
local domain
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
cat > "$LOCAL_CONFIG" <<EOF
{
"\$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [{ "pattern": "$domain", "custom_domain": true }],
"r2_buckets": [{ "binding": "REPORTS", "bucket_name": "$BUCKET_NAME" }],
"triggers": { "crons": ["17 3 * * *"] },
"vars": {
"PUBLIC_ORIGIN": "https://$domain",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
EOF
info "已生成本机 Worker 配置 services/share-card-worker/wrangler.local.jsonc"
}
assert_secrets_not_tracked() {
git check-ignore -q .env || fail ".env 未被 Git 忽略,请停止部署并检查 .gitignore"
if git ls-files --error-unmatch .env >/dev/null 2>&1; then
fail ".env 已被 Git 跟踪,请先从索引移除"
fi
if git diff --cached --name-only | grep -Eq '(^|/)\.env$|wrangler\.local\.jsonc$'; then
fail "敏感本机配置已被暂存,请先取消暂存"
fi
}
create_bucket_if_needed() {
local output
set +e
output="$(wrangler r2 bucket create "$BUCKET_NAME" --config "$LOCAL_CONFIG" 2>&1)"
local status=$?
set -e
if [[ $status -eq 0 ]]; then
printf '%s\n' "$output"
elif printf '%s' "$output" | grep -Eqi 'already exists|already owned|10004'; then
info "R2 Bucket 已存在,继续部署"
else
printf '%s\n' "$output" >&2
fail "创建 R2 Bucket 失败"
fi
}
put_secrets() {
local upload_token app_id app_secret
upload_token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
printf '%s' "$upload_token" | wrangler secret put UPLOAD_TOKEN --config "$LOCAL_CONFIG"
printf '%s' "$app_id" | wrangler secret put WECHAT_APP_ID --config "$LOCAL_CONFIG"
printf '%s' "$app_secret" | wrangler secret put WECHAT_APP_SECRET --config "$LOCAL_CONFIG"
}
verify_service() {
local domain health signature
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
health="$(curl -fsS --retry 5 --retry-delay 2 "https://$domain/health")"
printf '%s' "$health" | grep -q '"storage":"ready"' || fail "健康检查未返回 storage: ready"
signature="$(curl -fsS --retry 3 --retry-delay 2 "https://$domain/api/wx-signature?url=https%3A%2F%2F${domain}%2Fhealth")"
printf '%s' "$signature" | grep -q '"signature"' || fail "微信 JS-SDK 签名检查失败:$signature"
info "服务验证成功:https://$domain"
}
doctor() {
require_project
ensure_env_file
ensure_upload_token
assert_secrets_not_tracked
info "Node: $(node --version 2>/dev/null || printf '未安装')"
info "pnpm: $(pnpm --version 2>/dev/null || printf '未安装')"
if wrangler --version >/dev/null 2>&1; then
info "Wrangler 可用:$(wrangler --version | tail -n 1)"
else
fail "Wrangler 无法运行"
fi
local domain app_id app_secret
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
[[ -n "$domain" && "$domain" != "share.example.com" ]] && info "分享域名:已配置" || info "分享域名:缺失"
[[ -n "$app_id" ]] && info "微信 AppID:已配置" || info "微信 AppID:缺失"
[[ -n "$app_secret" ]] && info "微信 AppSecret:已配置" || info "微信 AppSecret:缺失"
info "UPLOAD_TOKEN:已配置"
}
deploy() {
require_project
ensure_env_file
ensure_upload_token
validate_required_config
assert_secrets_not_tracked
ensure_wrangler_login
generate_local_config
create_bucket_if_needed
put_secrets
wrangler deploy --config "$LOCAL_CONFIG"
verify_service
}
copy_token() {
ensure_env_file
ensure_upload_token
local token
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
if command -v pbcopy >/dev/null 2>&1; then
printf '%s' "$token" | pbcopy
elif command -v wl-copy >/dev/null 2>&1; then
printf '%s' "$token" | wl-copy
elif command -v xclip >/dev/null 2>&1; then
printf '%s' "$token" | xclip -selection clipboard
else
fail "未找到剪贴板工具;请让用户自行从 .env 读取 WECHAT_SHARE_UPLOAD_TOKEN"
fi
info "UPLOAD_TOKEN 已复制到剪贴板"
}
case "${1:-doctor}" in
doctor) doctor ;;
configure) configure_interactively ;;
deploy) deploy ;;
copy-token) copy_token ;;
*) fail "用法:$0 {doctor|configure|deploy|copy-token}" ;;
esac
+1
View File
@@ -44,6 +44,7 @@ const api = {
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
selectExportDirectory: () => electron.ipcRenderer.invoke("export:selectDirectory"),
onExportProgress: (callback) => {
const listener = (_event, progress) => callback(progress);
electron.ipcRenderer.on("export:progress", listener);
+8 -5
View File
@@ -1,8 +1,8 @@
{
"name": "tracememo",
"version": "2.2.0",
"version": "2.2.2",
"packageManager": "pnpm@7.33.7",
"description": "macOS / Windows 本地优先、可追溯的 AI 微信知识与分析工作台",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
"keywords": [
"wechat",
"wechat chat",
@@ -20,7 +20,7 @@
"author": "Qingmao",
"repository": {
"type": "git",
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
"url": "https://github.com/Wxw-Gu/TraceMemo.git"
},
"main": "./out/main/index.js",
"scripts": {
@@ -78,6 +78,7 @@
"jsonrepair": "^3.15.0",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"qrcode": "^1.5.4",
"sherpa-onnx-node": "1.13.3",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2"
@@ -95,6 +96,7 @@
"@types/archiver": "^8.0.0",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
@@ -113,7 +115,8 @@
"sass": "^1.102.0",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.1.10"
"vitest": "^4.1.10",
"wrangler": "^4.28.1"
},
"pnpm": {
"supportedArchitectures": {
@@ -132,4 +135,4 @@
"ffmpeg-static"
]
}
}
}
+856 -14
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

File diff suppressed because it is too large Load Diff
-32
View File
@@ -464,32 +464,6 @@
font-style: normal;
color: #98a2b3;
}
.gallery-card {
display: grid;
grid-template-columns: 112px 1fr;
gap: 12px;
margin-top: 10px;
padding: 12px;
background: #f7faf9;
border-radius: 14px;
}
.gallery-image {
width: 112px;
height: 112px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.gallery-stats {
display: inline-flex;
margin-top: 7px;
padding: 4px 8px;
border-radius: 999px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
font-weight: 700;
}
.badge-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -644,7 +618,6 @@
padding: 11px;
}
.compact .important-card,
.compact .gallery-card,
.compact .chat-block {
padding: 10px;
}
@@ -801,11 +774,6 @@
{{VISION_CARDS}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div>
-32
View File
@@ -465,32 +465,6 @@
font-style: normal;
color: #98a2b3;
}
.gallery-card {
display: grid;
grid-template-columns: 112px 1fr;
gap: 12px;
margin-top: 10px;
padding: 12px;
background: #f7faf9;
border-radius: 14px;
}
.gallery-image {
width: 112px;
height: 112px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.gallery-stats {
display: inline-flex;
margin-top: 7px;
padding: 4px 8px;
border-radius: 999px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
font-weight: 700;
}
.badge-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -645,7 +619,6 @@
padding: 11px;
}
.compact .important-card,
.compact .gallery-card,
.compact .chat-block {
padding: 10px;
}
@@ -801,11 +774,6 @@
{{VISION_CARDS}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div>
@@ -0,0 +1,244 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const fs = require('fs')
const path = require('path')
const { JSDOM } = require('jsdom')
const templates = [
{
id: 'mobile-feed',
className: 'template-mobile-feed',
label: 'Mobile 01',
name: '微信信息流',
width: 390
},
{
id: 'mobile-magazine',
className: 'template-mobile-magazine',
label: 'Mobile 02',
name: 'AI Magazine',
width: 390
},
{
id: 'mobile-dashboard',
className: 'template-mobile-dashboard',
label: 'Mobile 03',
name: 'AI Command Center',
width: 390
},
{
id: 'desktop-workspace',
className: 'template-desktop-workspace',
label: 'Desktop 01',
name: '三栏 AI 工作台',
width: 1440
},
{
id: 'desktop-editorial',
className: 'template-desktop-editorial',
label: 'Desktop 02',
name: 'Editorial 科技日报',
width: 1440
}
]
const sourcePath = path.resolve(process.argv[2] || '')
const outputDir = path.resolve(
process.argv[3] || path.join(process.cwd(), '.codex', 'report-template-preview')
)
const templatePath = path.join(process.cwd(), 'resources', 'daily_report_templates.html')
if (!sourcePath || !fs.existsSync(sourcePath)) {
console.error(
'用法: node scripts/generate-report-template-preview.cjs <现有日报.html> [输出目录]'
)
process.exit(1)
}
if (!fs.existsSync(templatePath)) {
console.error(`模板资源不存在: ${templatePath}`)
process.exit(1)
}
const document = new JSDOM(fs.readFileSync(sourcePath, 'utf8')).window.document
const templateHtml = fs.readFileSync(templatePath, 'utf8')
const one = (selector) => document.querySelector(selector)
const html = (selector) => one(selector)?.innerHTML || ''
const childrenAfterTitle = (selector) => {
const section = one(selector)
if (!section) return ''
return Array.from(section.children)
.filter((child) => !child.classList.contains('section-title'))
.map((child) => child.outerHTML)
.join('')
}
const sectionClass = (selector) => {
const section = one(selector)
return !section || section.classList.contains('empty-section') ? 'empty-section' : ''
}
const statValues = Array.from(document.querySelectorAll('.stat')).map((node) => {
const strong = node.querySelector('strong')?.textContent?.trim()
if (strong) return strong
return node.textContent?.trim().match(/[\d.]+\s*h?/i)?.[0] || ''
})
const title = one('.hero h1')?.textContent?.trim() || document.title
const subItems = Array.from(document.querySelectorAll('.sub > *'))
const dateTimeRange = subItems[0]?.textContent?.trim() || ''
const reportDate = dateTimeRange.match(/\d{4}-\d{2}-\d{2}/)?.[0] || ''
const recordNote = one('.record-note')?.textContent?.trim() || ''
const overview = one('.overview')?.textContent?.trim() || ''
const footer = one('.footer')?.textContent?.trim().replace(/\s+/g, ' ') || ''
const generatedAt = footer.match(/生成时间[:]\s*([^基]+?)(?:基于|$)/)?.[1]?.trim() || ''
const activityLine = Array.from(document.querySelectorAll('.analytics > .card')).find((node) =>
node.textContent?.includes('活跃时间线')
)
const values = {
REPORT_TITLE: title,
REPORT_DATE: reportDate,
DATE_RANGE: '今天',
TIME_SPAN: statValues[2] || dateTimeRange,
HERO_SUMMARY: overview,
HERO_TAKEAWAY: '',
HERO_PENDING: '',
HERO_STATUS_LINE: '',
HERO_AVATARS: html('.avatar-grid'),
HERO_AVATAR_CLASS: sectionClass('.avatar-grid'),
MESSAGE_COUNT: statValues[0] || '',
ACTIVE_USERS: statValues[1] || '',
TOPIC_COUNT: statValues[3] || '',
RECORD_NOTE: recordNote,
GENERATED_AT: generatedAt,
FOOTER_NOTE: footer,
TOPIC_CARDS: childrenAfterTitle('.topics'),
IMPORTANT_MESSAGES: childrenAfterTitle('.messages'),
QUOTE_BLOCKS: childrenAfterTitle('.quotes'),
QA_CARDS: childrenAfterTitle('.qa'),
HEAT_BARS: Array.from(document.querySelectorAll('.analytics > .heat-row'))
.map((node) => node.outerHTML)
.join(''),
RANK_ITEMS: Array.from(document.querySelectorAll('.analytics .rank'))
.map((node) => node.outerHTML)
.join(''),
ACTIVITY_TIMELINE: activityLine?.textContent?.trim() || '',
CLOUD_TAGS: html('.cloud-tags'),
RESOURCE_ITEMS: childrenAfterTitle('.resources'),
TODO_CARDS: '',
UNRESOLVED_CARDS: '',
STORYLINE_CARDS: '',
REVERSAL_CARDS: '',
CHAIN_CARDS: '',
VISION_TITLE: 'AI 识别的图片精选',
VISION_CARDS: childrenAfterTitle('.vision'),
VOICE_CARDS: '',
VOICE_RANK_CARDS: '',
BADGE_CARDS: '',
KEYWORDS_EMPTY_CLASS: sectionClass('.cloud'),
ANALYTICS_EMPTY_CLASS: sectionClass('.analytics'),
MESSAGES_EMPTY_CLASS: sectionClass('.messages'),
TOPICS_EMPTY_CLASS: sectionClass('.topics'),
QUOTES_EMPTY_CLASS: sectionClass('.quotes'),
RESOURCES_EMPTY_CLASS: sectionClass('.resources'),
QA_EMPTY_CLASS: sectionClass('.qa'),
ACTIONS_EMPTY_CLASS: 'empty-section',
STORYLINES_EMPTY_CLASS: 'empty-section',
REVERSALS_EMPTY_CLASS: 'empty-section',
CHAINS_EMPTY_CLASS: 'empty-section',
VISION_EMPTY_CLASS: sectionClass('.vision'),
VOICE_EMPTY_CLASS: 'empty-section',
VOICE_RANK_EMPTY_CLASS: 'empty-section',
BADGES_EMPTY_CLASS: 'empty-section',
HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section',
HERO_PENDING_EMPTY_CLASS: 'empty-section',
HERO_STATUS_EMPTY_CLASS: 'empty-section'
}
const escapeHtml = (value) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
const render = (template) => {
let result = templateHtml
const replacements = {
...values,
TEMPLATE_CLASS: template.className,
TEMPLATE_LABEL: template.label,
TEMPLATE_NAME: template.name
}
const htmlKeys = new Set([
'HERO_AVATARS',
'TOPIC_CARDS',
'IMPORTANT_MESSAGES',
'QUOTE_BLOCKS',
'QA_CARDS',
'HEAT_BARS',
'RANK_ITEMS',
'CLOUD_TAGS',
'RESOURCE_ITEMS',
'TODO_CARDS',
'UNRESOLVED_CARDS',
'STORYLINE_CARDS',
'REVERSAL_CARDS',
'CHAIN_CARDS',
'VISION_CARDS',
'VOICE_CARDS',
'VOICE_RANK_CARDS',
'BADGE_CARDS'
])
for (const [key, value] of Object.entries(replacements)) {
const safeValue = htmlKeys.has(key) ? String(value || '') : escapeHtml(value)
result = result.replaceAll(`{{${key}}}`, safeValue)
}
return result.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
}
fs.mkdirSync(outputDir, { recursive: true })
for (const template of templates) {
fs.writeFileSync(path.join(outputDir, `${template.id}.html`), render(template), 'utf8')
}
const reportName = title.replace(/日报$/, '')
const buttons = templates
.map(
(template, index) => `
<button class="${index === 0 ? 'active' : ''}" data-src="${template.id}.html" data-width="${template.width}">
<span>${template.label}</span><b>${template.name}</b>
</button>`
)
.join('')
const indexHtml = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(reportName)} · 五套日报模板预览</title>
<style>
*{box-sizing:border-box} body{margin:0;background:#e9eeeb;color:#17211d;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
header{position:sticky;top:0;z-index:2;padding:18px 24px 14px;background:rgba(255,255,255,.95);border-bottom:1px solid #d7e0da;backdrop-filter:blur(14px)}
h1{margin:0;font-size:20px} p{margin:5px 0 0;color:#68736c;font-size:12px}.toolbar{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding-bottom:2px}
button{display:grid;flex:0 0 auto;gap:2px;min-width:142px;padding:9px 12px;border:1px solid #d8e1db;border-radius:9px;background:#fff;color:#24332b;text-align:left;cursor:pointer}
button span{color:#708078;font-size:9px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}button b{font-size:12px}button.active{border-color:#16835b;background:#eaf6ef;color:#0d6744}
.stage{display:flex;justify-content:center;min-height:calc(100vh - 132px);padding:24px;overflow:auto}.frame-shell{width:390px;max-width:100%;overflow:hidden;border:1px solid #cbd6cf;border-radius:14px;background:white;box-shadow:0 18px 48px rgba(30,55,43,.15);transition:width .2s ease}
iframe{display:block;width:100%;height:calc(100vh - 180px);min-height:680px;border:0;background:white}
@media(max-width:640px){header{padding:14px 12px 12px}.stage{padding:12px}.frame-shell{border-radius:10px}button{min-width:132px}}
</style>
</head>
<body>
<header><h1>${escapeHtml(reportName)} · 五套日报模板</h1><p>同一份 2026-08-11 真实日报数据,可直接切换比较布局、排版和信息密度。</p><div class="toolbar">${buttons}</div></header>
<main class="stage"><div class="frame-shell"><iframe title="日报模板预览" src="mobile-feed.html"></iframe></div></main>
<script>
const frame=document.querySelector('iframe');const shell=document.querySelector('.frame-shell');
document.querySelectorAll('button').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('button').forEach(item=>item.classList.remove('active'));button.classList.add('active');frame.src=button.dataset.src;shell.style.width=button.dataset.width+'px'}));
</script>
</body>
</html>`
fs.writeFileSync(path.join(outputDir, 'index.html'), indexHtml, 'utf8')
console.log(path.join(outputDir, 'index.html'))
+1 -23
View File
@@ -182,7 +182,6 @@ fullRequest.report.sectionMeta = {
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
@@ -218,24 +217,7 @@ fullRequest.report.reversals = [
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
]
fullRequest.report.media = {
gallery: [
{
sender: '阿宇',
time: '09:52',
imageUrl: sampleImage,
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
stats: '12 条后续消息 · 6 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
},
{
sender: '佩佩',
time: '17:14',
imageUrl: sampleImage,
note: '第二张图带起一轮轻松但有效的快速确认。',
stats: '5 条后续消息 · 3 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
}
],
gallery: [],
voiceHighlights: [
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
@@ -281,7 +263,6 @@ async function renderRequest(request, targetBase) {
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
@@ -343,9 +324,6 @@ async function renderRequest(request, targetBase) {
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
REVERSAL_CARDS: reversalCards,
REVERSALS_MORE_NOTE: '',
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
GALLERY_CARDS: galleryCards,
GALLERY_MORE_NOTE: '',
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: '',
+26
View File
@@ -0,0 +1,26 @@
# WechatExplorer share-card worker
Cloudflare Worker + private R2 service for temporary WeChat report cards.
This is an experimental, self-hosted feature. See the complete Chinese deployment guide:
- `../../docs/deployment/experimental-wechat-share-card.md`
Required encrypted secrets:
- `WECHAT_APP_ID`
- `WECHAT_APP_SECRET`
- `UPLOAD_TOKEN` (random 32+ character token also saved in WechatExplorer's secure settings)
Create the private bucket, set secrets, and deploy:
```bash
npx wrangler r2 bucket create wechatexplorer-share-reports
npx wrangler secret put WECHAT_APP_ID
npx wrangler secret put WECHAT_APP_SECRET
npx wrangler secret put UPLOAD_TOKEN
npx wrangler deploy
```
Keep the R2 public development URL disabled. All reads go through the Worker and expire with
the card.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "wechatexplorer-share-card-worker",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"check": "node --check src/index.js && node --test test/index.test.js"
},
"devDependencies": {
"wrangler": "^4.28.1"
}
}
+318
View File
@@ -0,0 +1,318 @@
const encoder = new TextEncoder()
const DAY_MS = 86_400_000
// Add the TXT filename and content supplied by the WeChat test-account page when
// domain verification is required. Never commit a real verification value.
const WECHAT_DOMAIN_VERIFICATION = new Map()
const json = (value, init = {}) =>
new Response(JSON.stringify(value), {
...init,
headers: { 'content-type': 'application/json; charset=utf-8', ...(init.headers || {}) }
})
const escapeHtml = (value) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const safeJson = (value) => JSON.stringify(value).replaceAll('<', '\\u003c')
const cardKey = (id) => `cards/${id}/card.json`
const imageKey = (id) => `cards/${id}/report.png`
const thumbnailKey = (id) => `cards/${id}/thumbnail.jpg`
const storageUnavailable = () =>
json(
{
error: '图片存储服务尚未启用,请在 Cloudflare 控制台启用 R2 后重新生成分享卡片'
},
{ status: 503 }
)
const readCard = async (env, id) => {
if (!/^[0-9a-f-]{36}$/i.test(id)) return null
const object = await env.REPORTS.get(cardKey(id))
if (!object) return null
const card = JSON.parse(await object.text())
if (Date.parse(card.expiresAt) <= Date.now()) {
await deleteCard(env, id)
return null
}
return card
}
const deleteCard = async (env, id) => {
await env.REPORTS.delete([cardKey(id), imageKey(id), thumbnailKey(id)])
}
const bearerAuthorized = (request, env) => {
const value = request.headers.get('authorization') || ''
return Boolean(env.UPLOAD_TOKEN) && value === `Bearer ${env.UPLOAD_TOKEN}`
}
const publicOrigin = (request, env) =>
String(env.PUBLIC_ORIGIN || new URL(request.url).origin).replace(/\/+$/, '')
const createCard = async (request, env) => {
if (!bearerAuthorized(request, env)) return json({ error: '未授权' }, { status: 401 })
const body = await request.json().catch(() => null)
if (!body) return json({ error: '请求体无效' }, { status: 400 })
const title = String(body.title || '')
.trim()
.slice(0, 64)
const description = String(body.description || '')
.trim()
.slice(0, 120)
if (!title || !body.imageBase64 || !body.thumbnailBase64) {
return json({ error: '缺少标题或图片' }, { status: 400 })
}
const image = Uint8Array.from(atob(body.imageBase64), (char) => char.charCodeAt(0))
const thumbnail = Uint8Array.from(atob(body.thumbnailBase64), (char) => char.charCodeAt(0))
if (image.byteLength > 25 * 1024 * 1024 || thumbnail.byteLength > 2 * 1024 * 1024) {
return json({ error: '图片超过大小限制' }, { status: 413 })
}
const id = crypto.randomUUID()
const days = Math.max(1, Math.min(30, Number(body.expiresInDays || env.DEFAULT_EXPIRY_DAYS || 7)))
const createdAt = new Date().toISOString()
const expiresAt = new Date(Date.now() + days * DAY_MS).toISOString()
const card = { id, title, description, createdAt, expiresAt }
await Promise.all([
env.REPORTS.put(cardKey(id), JSON.stringify(card), {
httpMetadata: { contentType: 'application/json; charset=utf-8' }
}),
env.REPORTS.put(imageKey(id), image, {
httpMetadata: { contentType: 'image/png', cacheControl: 'private, max-age=300' }
}),
env.REPORTS.put(thumbnailKey(id), thumbnail, {
httpMetadata: { contentType: 'image/jpeg', cacheControl: 'public, max-age=300' }
})
])
const origin = publicOrigin(request, env)
return json({
cardId: id,
shareUrl: `${origin}/s/${id}`,
viewUrl: `${origin}/v/${id}`,
expiresAt
})
}
const serveAsset = async (env, id, kind) => {
const card = await readCard(env, id)
if (!card) return new Response('Not found', { status: 404 })
const object = await env.REPORTS.get(kind === 'thumbnail' ? thumbnailKey(id) : imageKey(id))
if (!object) return new Response('Not found', { status: 404 })
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('x-content-type-options', 'nosniff')
headers.set('cache-control', kind === 'thumbnail' ? 'public, max-age=300' : 'private, max-age=60')
return new Response(object.body, { headers })
}
const sharePage = (request, env, card) => {
const origin = publicOrigin(request, env)
const viewUrl = `${origin}/v/${card.id}`
const cardLinkUrl = `${origin}/l/${card.id}`
const imageUrl = `${origin}/a/${card.id}/thumbnail`
const share = { title: card.title, desc: card.description, link: cardLinkUrl, imgUrl: imageUrl }
return new Response(
`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta property="og:title" content="${escapeHtml(card.title)}">
<meta property="og:description" content="${escapeHtml(card.description)}">
<meta property="og:image" content="${escapeHtml(imageUrl)}">
<title>${escapeHtml(card.title)}</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f3f6f5;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{min-height:100vh;padding:48px 24px;display:flex;align-items:center;justify-content:center}
.card{width:min(100%,440px);background:#fff;border-radius:24px;padding:30px;box-shadow:0 18px 60px rgba(23,32,29,.10);text-align:center}
.arrow{font-size:52px;color:#16a66a;transform:rotate(-20deg);margin:-10px 0 12px}
h1{font-size:23px;margin:0 0 12px}.desc{color:#61706a;line-height:1.7;margin:0 0 28px}
.hint{background:#ecf8f2;border:1px solid #cdebdc;border-radius:16px;padding:18px;line-height:1.7}
.open{display:inline-block;margin-top:22px;color:#08794c;text-decoration:none;font-weight:650}
#status{font-size:13px;color:#7f8d87;margin-top:18px}
</style>
</head>
<body><main><section class="card">
<div class="arrow">↗</div>
<h1>点击右上角 ··· 分享</h1>
<p class="desc">${escapeHtml(card.description)}</p>
<div class="hint">发送给好友或群聊后,将显示为标题、描述和缩略图组成的微信卡片。</div>
<a class="open" href="${escapeHtml(viewUrl)}">先查看完整日报</a>
<p id="status">正在准备微信分享信息…</p>
</section></main>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
const share=${safeJson(share)};
const status=document.getElementById('status');
fetch('/api/wx-signature?url='+encodeURIComponent(location.href.split('#')[0]))
.then(r=>r.json().then(data=>({ok:r.ok,data})))
.then(({ok,data})=>{
if(!ok) throw new Error(data.error||'签名失败');
wx.config({...data,debug:false,jsApiList:['updateAppMessageShareData','updateTimelineShareData']});
wx.ready(()=>{
wx.updateAppMessageShareData({...share,success:()=>status.textContent='分享卡片已准备好'});
wx.updateTimelineShareData({title:share.title,link:share.link,imgUrl:share.imgUrl});
status.textContent='分享卡片已准备好';
});
wx.error(err=>{status.textContent='微信分享配置失败:'+(err.errMsg||'未知错误')});
})
.catch(err=>{status.textContent='微信分享配置失败:'+err.message});
</script></body></html>`,
{
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy':
"default-src 'self'; script-src 'self' 'unsafe-inline' https://res.wx.qq.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
}
}
)
}
const viewPage = (env, card) => {
const origin = String(env.PUBLIC_ORIGIN).replace(/\/+$/, '')
return new Response(
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(card.title)}</title><style>*{box-sizing:border-box}body{margin:0;background:#eef2f0;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{padding:20px;background:#fff;position:sticky;top:0;box-shadow:0 1px 8px #0001}h1{font-size:18px;margin:0 0 6px}p{margin:0;color:#68746f;font-size:13px}.image{display:block;width:min(100%,900px);height:auto;margin:20px auto;background:#fff}</style></head><body><header><h1>${escapeHtml(card.title)}</h1><p>${escapeHtml(card.description)} · 有效期至 ${escapeHtml(card.expiresAt.slice(0, 10))}</p></header><img class="image" src="${origin}/a/${card.id}/report" alt="${escapeHtml(card.title)}"></body></html>`,
{
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy': "default-src 'self'; img-src 'self'; style-src 'unsafe-inline'"
}
}
)
}
const cachedWechatValue = async (cacheKey, ttl, loader) => {
const cache = caches.default
const request = new Request(`https://wechat-cache.invalid/${cacheKey}`)
const cached = await cache.match(request)
if (cached) return cached.json()
const value = await loader()
await cache.put(request, json(value, { headers: { 'cache-control': `public, max-age=${ttl}` } }))
return value
}
const getAccessToken = (env) =>
cachedWechatValue(`access-token/${env.WECHAT_APP_ID}`, 6900, async () => {
const url = new URL('https://api.weixin.qq.com/cgi-bin/token')
url.searchParams.set('grant_type', 'client_credential')
url.searchParams.set('appid', env.WECHAT_APP_ID)
url.searchParams.set('secret', env.WECHAT_APP_SECRET)
const data = await fetch(url).then((response) => response.json())
if (!data.access_token) throw new Error(data.errmsg || '无法获取 access_token')
return { value: data.access_token }
})
const getTicket = async (env) => {
const token = await getAccessToken(env)
return cachedWechatValue(`jsapi-ticket/${env.WECHAT_APP_ID}`, 6900, async () => {
const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket')
url.searchParams.set('access_token', token.value)
url.searchParams.set('type', 'jsapi')
const data = await fetch(url).then((response) => response.json())
if (!data.ticket) throw new Error(data.errmsg || '无法获取 jsapi_ticket')
return { value: data.ticket }
})
}
const sha1 = async (value) => {
const digest = await crypto.subtle.digest('SHA-1', encoder.encode(value))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}
const signature = async (request, env) => {
if (!env.WECHAT_APP_ID || !env.WECHAT_APP_SECRET) {
return json({ error: '微信 JS-SDK 尚未配置' }, { status: 503 })
}
const pageUrl = new URL(request.url).searchParams.get('url')
if (!pageUrl) return json({ error: '缺少签名 URL' }, { status: 400 })
const parsed = new URL(pageUrl)
if (parsed.origin !== publicOrigin(request, env)) {
return json({ error: '只能签名当前分享域名' }, { status: 400 })
}
const ticket = await getTicket(env)
const nonceStr = crypto.randomUUID().replaceAll('-', '')
const timestamp = Math.floor(Date.now() / 1000)
const source = `jsapi_ticket=${ticket.value}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${pageUrl}`
return json({
appId: env.WECHAT_APP_ID,
timestamp,
nonceStr,
signature: await sha1(source)
})
}
const router = async (request, env) => {
const url = new URL(request.url)
const verificationContent = WECHAT_DOMAIN_VERIFICATION.get(url.pathname.slice(1))
if (request.method === 'GET' && verificationContent) {
return new Response(verificationContent, {
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=300',
'x-content-type-options': 'nosniff'
}
})
}
if (request.method === 'GET' && url.pathname === '/health') {
return json({
ok: true,
service: 'wechatexplorer-share-card',
storage: env.REPORTS ? 'ready' : 'unavailable'
})
}
if (!env.REPORTS && (url.pathname === '/api/cards' || /^\/(s|l|v|a)\//i.test(url.pathname))) {
return storageUnavailable()
}
if (request.method === 'POST' && url.pathname === '/api/cards') return createCard(request, env)
if (request.method === 'GET' && url.pathname === '/api/wx-signature') {
try {
return await signature(request, env)
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 502 }
)
}
}
const match = url.pathname.match(/^\/(s|l|v|a)\/([0-9a-f-]{36})(?:\/(report|thumbnail))?$/i)
if (!match) return new Response('Not found', { status: 404 })
const [, route, id, asset] = match
if (route === 'a') return serveAsset(env, id, asset)
const card = await readCard(env, id)
if (!card) return new Response('卡片不存在或已过期', { status: 404 })
if (route === 'l') {
return Response.redirect(`${publicOrigin(request, env)}/v/${card.id}`, 302)
}
return route === 's' ? sharePage(request, env, card) : viewPage(env, card)
}
const cleanup = async (env) => {
let cursor
do {
const listed = await env.REPORTS.list({ prefix: 'cards/', cursor, include: ['httpMetadata'] })
const metadataObjects = listed.objects.filter((object) => object.key.endsWith('/card.json'))
for (const item of metadataObjects) {
const object = await env.REPORTS.get(item.key)
if (!object) continue
const card = JSON.parse(await object.text())
if (Date.parse(card.expiresAt) <= Date.now()) await deleteCard(env, card.id)
}
cursor = listed.truncated ? listed.cursor : undefined
} while (cursor)
}
export default {
fetch: (request, env) => router(request, env),
scheduled: (_controller, env, ctx) => ctx.waitUntil(cleanup(env))
}
export { escapeHtml, sha1 }
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import worker, { escapeHtml, sha1 } from '../src/index.js'
class MemoryR2Object {
constructor(value, metadata = {}) {
this.value = value
this.metadata = metadata
}
async text() {
return new TextDecoder().decode(this.value)
}
get body() {
return this.value
}
writeHttpMetadata(headers) {
if (this.metadata.contentType) headers.set('content-type', this.metadata.contentType)
if (this.metadata.cacheControl) headers.set('cache-control', this.metadata.cacheControl)
}
}
class MemoryR2 {
objects = new Map()
async put(key, value, options = {}) {
const bytes =
typeof value === 'string' ? new TextEncoder().encode(value) : new Uint8Array(value)
this.objects.set(key, new MemoryR2Object(bytes, options.httpMetadata))
}
async get(key) {
return this.objects.get(key) || null
}
async delete(keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) this.objects.delete(key)
}
}
const env = () => ({
REPORTS: new MemoryR2(),
UPLOAD_TOKEN: 'test-upload-token-that-is-long-enough',
PUBLIC_ORIGIN: 'https://share.example.com',
DEFAULT_EXPIRY_DAYS: '7'
})
test('requires the upload bearer token', async () => {
const response = await worker.fetch(
new Request('https://share.example.com/api/cards', { method: 'POST', body: '{}' }),
env()
)
assert.equal(response.status, 401)
})
test('returns a controlled error when R2 is not configured', async () => {
const response = await worker.fetch(
new Request('https://share.example/s/d9069d5a-d1a0-44fc-a983-2602c3f1cb94'),
{ PUBLIC_ORIGIN: 'https://share.example' }
)
assert.equal(response.status, 503)
assert.match(await response.text(), /图片存储服务尚未启用/)
})
test('creates an expiring card and serves only its random assets', async () => {
const testEnv = env()
const response = await worker.fetch(
new Request('https://share.example.com/api/cards', {
method: 'POST',
headers: {
authorization: `Bearer ${testEnv.UPLOAD_TOKEN}`,
'content-type': 'application/json'
},
body: JSON.stringify({
title: '技术交流群日报',
description: '今日群聊总结',
imageBase64: Buffer.from('png-data').toString('base64'),
thumbnailBase64: Buffer.from('jpeg-data').toString('base64')
})
}),
testEnv
)
assert.equal(response.status, 200)
const card = await response.json()
assert.match(card.cardId, /^[0-9a-f-]{36}$/)
assert.equal(card.shareUrl, `https://share.example.com/s/${card.cardId}`)
const page = await worker.fetch(new Request(card.shareUrl), testEnv)
assert.equal(page.status, 200)
const pageHtml = await page.text()
assert.match(pageHtml, /updateAppMessageShareData/)
assert.match(pageHtml, new RegExp(`/l/${card.cardId}`))
const link = await worker.fetch(
new Request(`https://share.example.com/l/${card.cardId}`),
testEnv
)
assert.equal(link.status, 302)
assert.equal(link.headers.get('location'), `https://share.example.com/v/${card.cardId}`)
const asset = await worker.fetch(
new Request(`https://share.example.com/a/${card.cardId}/thumbnail`),
testEnv
)
assert.equal(asset.status, 200)
assert.equal(await asset.text(), 'jpeg-data')
})
test('escapes untrusted card metadata and produces the expected SHA-1', async () => {
assert.equal(
escapeHtml(`<img src=x onerror="alert('x')">&`),
'&lt;img src=x onerror=&quot;alert(&#39;x&#39;)&quot;&gt;&amp;'
)
assert.equal(await sha1('abc'), 'a9993e364706816aba3e25717850c26c9cd0d89d')
})
test('removes expired cards when they are requested', async () => {
const testEnv = env()
const id = '11111111-1111-4111-8111-111111111111'
await testEnv.REPORTS.put(
`cards/${id}/card.json`,
JSON.stringify({
id,
title: 'expired',
description: '',
expiresAt: new Date(Date.now() - 1000).toISOString()
})
)
await testEnv.REPORTS.put(`cards/${id}/report.png`, 'image')
await testEnv.REPORTS.put(`cards/${id}/thumbnail.jpg`, 'thumb')
const response = await worker.fetch(new Request(`https://share.example.com/s/${id}`), testEnv)
assert.equal(response.status, 404)
assert.equal(testEnv.REPORTS.objects.size, 0)
})
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"r2_buckets": [
{
"binding": "REPORTS",
"bucket_name": "wechatexplorer-share-reports"
}
],
"triggers": {
"crons": ["17 3 * * *"]
},
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
@@ -0,0 +1,16 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
+59 -4
View File
@@ -18,8 +18,10 @@ import {
type UserDataSelection
} from './app-data-paths'
import type { LegacySecretBundle } from './legacy-safe-storage-helper'
import { SqliteTranscriptRepository } from './voice-pipeline/transcript-repository'
const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json'
const VOICE_TRANSCRIPT_CACHE_ITEM = 'cache/voice-transcripts.sqlite'
const TOKEN_MIGRATION_BLOCK_MESSAGE =
'检测到旧版 API Token 尚未完成迁移。请重试数据迁移,或在 API Center 主动重新生成 Token。'
@@ -289,6 +291,24 @@ async function copyDirectoryWithoutOverwrite(
return 'migrated'
}
export async function migrateLegacyVoiceTranscripts(
sourceRoot: string,
targetRoot: string
): Promise<MigrationItemStatus> {
const relativePath = path.join('cache', 'voice-transcripts.sqlite')
const sourcePath = path.join(sourceRoot, relativePath)
const targetPath = path.join(targetRoot, relativePath)
if (!(await fs.pathExists(sourcePath))) return 'missing'
if (path.resolve(sourcePath) === path.resolve(targetPath)) return 'skipped'
const repository = new SqliteTranscriptRepository(targetPath)
try {
return repository.mergeFrom(sourcePath) > 0 ? 'migrated' : 'skipped'
} finally {
repository.close()
}
}
async function writeEncryptedWithoutOverwrite(
targetRoot: string,
relativePath: string,
@@ -472,6 +492,9 @@ export async function executeMigration(
copyDirectoryWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
)
}
await runItem(VOICE_TRANSCRIPT_CACHE_ITEM, () =>
migrateLegacyVoiceTranscripts(sourceRoot, targetRoot)
)
const agentRoots = dependencies.agentRoots()
await runItem('agent-credentials', () => copyMissingTree(agentRoots.legacy, agentRoots.current))
@@ -528,7 +551,9 @@ export async function executeMigration(
await fs.remove(stagingRoot).catch(() => undefined)
}
const failed = Object.values(items).some((status) => status === 'failed')
const failed = Object.entries(items).some(
([name, status]) => name !== VOICE_TRANSCRIPT_CACHE_ITEM && status === 'failed'
)
state.status = failed || secretFailures.length > 0 ? 'partial' : 'completed'
state.updatedAt = timestamp()
state.secretFailures = Array.from(new Set(secretFailures))
@@ -592,6 +617,38 @@ function closeMigrationProgress(window: BrowserWindow | null): void {
export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<MigrationFlowResult> {
const assessment = assessMigration(roots)
const voiceMigrationStatus = assessment.state?.items[VOICE_TRANSCRIPT_CACHE_ITEM]
const needsVoiceBackfill = !voiceMigrationStatus
if (
assessment.reason === 'migration-completed' &&
assessment.sourceRoot &&
assessment.state &&
needsVoiceBackfill
) {
const state: MigrationState = {
...assessment.state,
items: { ...assessment.state.items },
updatedAt: new Date().toISOString()
}
try {
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = await migrateLegacyVoiceTranscripts(
assessment.sourceRoot,
roots.current
)
} catch {
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = 'failed'
}
await writeMigrationState(roots.current, state)
return {
assessment: { ...assessment, state },
action: state.items[VOICE_TRANSCRIPT_CACHE_ITEM] === 'migrated' ? 'migrated' : 'none',
execution: {
state,
tokenGenerationBlocked: false
},
tokenGenerationBlocked: false
}
}
if (!assessment.shouldPrompt || !assessment.sourceRoot) {
return { assessment, action: 'none', tokenGenerationBlocked: false }
}
@@ -649,9 +706,7 @@ export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<Mig
type: execution.state.status === 'completed' ? ('info' as const) : ('warning' as const),
title: 'TraceMemo 数据迁移',
message:
execution.state.status === 'completed'
? 'WechatExplorer 数据迁移完成'
: '部分数据未能迁移',
execution.state.status === 'completed' ? 'WechatExplorer 数据迁移完成' : '部分数据未能迁移',
detail:
execution.state.status === 'completed'
? '核心用户资产已复制到 TraceMemo。旧目录仍完整保留。'
+2 -1
View File
@@ -131,7 +131,8 @@ export function hasValidUserAssets(root: string): boolean {
'wechat-db-key.bin',
'wechat-image-keys.bin',
'image-insights.json',
'wechat-share-service.bin'
'wechat-share-service.bin',
path.join('cache', 'voice-transcripts.sqlite')
]
if (markers.some((marker) => isNonEmptyFile(path.join(root, marker)))) return true
if (hasKnowledgeDatabase(root)) return true
+17 -2
View File
@@ -995,6 +995,19 @@ const renderExportScript = (name: string): string => `
message.contentData && forwardedSearchText(message.contentData.items),
message.exportMediaName
].filter(Boolean).join(' ').toLowerCase()
const exportedImageOrVideoFileStem = (message) => {
const mediaType = message.exportMediaType || (message.contentData && message.contentData.type)
if (mediaType !== 'image' && mediaType !== 'video') return ''
const mediaUrl = String(message.exportMediaUrl || '').split(/[?#]/, 1)[0]
const fileName = mediaUrl.slice(mediaUrl.lastIndexOf('/') + 1)
if (!fileName) return ''
let decodedFileName = fileName
try {
decodedFileName = decodeURIComponent(fileName)
} catch {}
const extensionIndex = decodedFileName.lastIndexOf('.')
return (extensionIndex > 0 ? decodedFileName.slice(0, extensionIndex) : decodedFileName).toLowerCase()
}
const shareLabel = (typeVal) => {
if (String(typeVal) === '5') return '公众号链接'
@@ -1516,7 +1529,9 @@ const renderExportScript = (name: string): string => `
return allMessages.filter((message) =>
(activeConversation === 'all' || message.exportConversationId === activeConversation) &&
(activeKind === 'all' || kindOf(message) === activeKind) &&
(!term || searchText(message).includes(term))
(!term ||
searchText(message).includes(term) ||
exportedImageOrVideoFileStem(message) === term)
)
}
const applyFilters = (restorePosition = false) => {
@@ -1779,7 +1794,7 @@ export function renderExportPage(name: string): string {
</div>
</div>
<div class="controls">
<input id="query" type="search" placeholder="搜索发送者消息内容…" aria-label="搜索消息">
<input id="query" type="search" placeholder="搜索发送者消息内容或媒体文件名(不含后缀)…" aria-label="搜索消息">
</div>
<div class="filters" id="filters">
<button class="filter-button active" type="button" data-kind="all">全部</button>
+157 -68
View File
@@ -275,6 +275,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
if (!current.exportMediaUrl && !current.voiceDataUrl && previous.exportMediaError) {
merged.exportMediaError = previous.exportMediaError
}
if (merged.voiceDataUrl) delete merged.exportMediaError
if (merged.voiceTranscript) delete merged.voiceTranscriptError
return merged
}
@@ -754,7 +756,8 @@ const preserveLegacyCombinedArchive = async (outputDir: string): Promise<void> =
async function runSingleExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>,
options: SingleExportOptions = {}
): Promise<ExportResult> {
const manageJob = options.manageJob !== false
@@ -925,7 +928,7 @@ async function runSingleExport(
request.format === 'html'
? options.outputFolderName || safeFilePart(request.outputName)
: `${safeFilePart(request.outputName)}_${exportStamp()}`
const root = options.outputRoot || (await resolveDefaultExportRoot(outputFolder))
const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
await fs.mkdir(root, { recursive: true })
const outputDir = join(root, outputFolder)
const outputPath =
@@ -1083,89 +1086,173 @@ async function runSingleExport(
total: voiceMessages.length,
percent: 20
})
for (const [voiceIndex, message] of voiceMessages.entries()) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
const previous = reusablePreviousMessages.get(message)
let canTranscribe = true
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
message.voiceDataUrl = previous.voiceDataUrl
message.voiceDuration = previous.voiceDuration
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
message.voiceTranscript = previous.voiceTranscript
const voiceIndexUpdates = new Map<
string,
{
reference: {
sessionId: string
localId: number
createTime: number
svrId?: string | number
}
} else if (!message.sessionId || message.localId == null || !message.createTime) {
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
canTranscribe = false
} else {
try {
const voice = await voiceService.resolveVoice(
message.sessionId,
message.localId,
message.createTime,
message.serverId
)
if (!voice.success || !voice.data) {
const detail = voice.error || '未知原因'
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
? `${detail}`
: /Silk|解码|数据为空/.test(detail)
? `${detail}`
: `${detail}`
keepMediaError(request, message, reason)
canTranscribe = false
} else {
const audioBuffer = Buffer.from(voice.data, 'base64')
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
const voiceUrl = `voices/${voiceName}`
if (!(await resourceExists(voiceUrl))) {
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
markResourceExists(voiceUrl)
transcript: string
cached: boolean
}
>()
const batchSize = 16
for (let batchStart = 0; batchStart < voiceMessages.length; batchStart += batchSize) {
const batch = voiceMessages.slice(batchStart, batchStart + batchSize)
const mediaItems: Array<{
message: Message
reference: {
sessionId: string
localId: number
createTime: number
svrId?: string | number
}
}> = []
for (const message of batch) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
const previous = reusablePreviousMessages.get(message)
const hasVoiceIdentity = Boolean(
message.sessionId && message.localId != null && message.createTime
)
const reference = hasVoiceIdentity
? {
sessionId: message.sessionId!,
localId: message.localId!,
createTime: message.createTime!,
svrId: message.serverId
}
message.voiceDataUrl = voiceUrl
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
: null
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
message.voiceDataUrl = previous.voiceDataUrl
message.voiceDuration = previous.voiceDuration
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
message.voiceTranscript = previous.voiceTranscript
}
}
if (request.includeVoiceTranscripts && !message.voiceTranscript) {
if (!reference) {
message.voiceTranscriptError = '语音标识不完整,无法转文字'
} else if (!voiceRecognition) {
message.voiceTranscriptError = '语音转文字服务不可用'
} else {
try {
const recognition = await voiceRecognition.recognize(
reference,
voiceRecognition.publishTranscript
? { publishTranscriptUpdate: false }
: undefined
)
if (recognition.success) {
message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字'
if (voiceRecognition.publishTranscript && recognition.transcript?.trim()) {
voiceIndexUpdates.set(reference.sessionId, {
reference,
transcript: recognition.transcript.trim(),
cached: Boolean(recognition.cached)
})
}
} else {
message.voiceTranscriptError = recognition.error || '语音识别失败'
}
} catch (error) {
message.voiceTranscriptError =
error instanceof Error ? error.message : '语音识别失败'
}
}
}
if (
request.includeVoiceTranscripts &&
reference &&
message.voiceTranscript &&
voiceRecognition?.publishTranscript &&
!voiceIndexUpdates.has(reference.sessionId)
) {
voiceIndexUpdates.set(reference.sessionId, {
reference,
transcript: message.voiceTranscript,
cached: true
})
}
if (!message.voiceDataUrl) {
if (!reference) {
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
} else {
mediaItems.push({ message, reference })
}
}
}
const voices =
typeof voiceService.resolveVoices === 'function'
? await voiceService.resolveVoices(mediaItems.map((item) => item.reference))
: await Promise.all(
mediaItems.map(({ reference }) =>
voiceService.resolveVoice(
reference.sessionId,
reference.localId,
reference.createTime,
reference.svrId
)
)
)
for (const [{ message }, voice] of mediaItems.map(
(item, index) => [item, voices[index]] as const
)) {
if (!voice?.success || !voice.data) {
const detail = voice?.error || '未知原因'
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
? `${detail}`
: /Silk|解码|数据为空/.test(detail)
? `${detail}`
: `${detail}`
keepMediaError(request, message, reason)
continue
}
try {
const audioBuffer = Buffer.from(voice.data, 'base64')
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
const voiceUrl = `voices/${voiceName}`
if (!(await resourceExists(voiceUrl))) {
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
markResourceExists(voiceUrl)
}
message.voiceDataUrl = voiceUrl
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
} catch (error) {
keepMediaError(
request,
message,
`${error instanceof Error ? error.message : String(error)}`
)
canTranscribe = false
}
}
if (request.includeVoiceTranscripts && canTranscribe && !message.voiceTranscript) {
if (!voiceRecognition) {
message.voiceTranscriptError = '语音转文字服务不可用'
} else {
try {
const recognition = await voiceRecognition.recognize({
sessionId: message.sessionId!,
localId: message.localId!,
createTime: message.createTime!,
svrId: message.serverId
})
if (recognition.success) {
message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字'
} else {
message.voiceTranscriptError = recognition.error || '语音识别失败'
}
} catch (error) {
message.voiceTranscriptError =
error instanceof Error ? error.message : '语音识别失败'
}
}
}
const processedVoices = Math.min(batchStart + batch.length, voiceMessages.length)
send({
jobId: request.jobId,
phase: voicePhase,
processed: voiceIndex + 1,
processed: processedVoices,
total: voiceMessages.length,
percent:
20 +
Math.round(
((voiceIndex + 1) / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
(processedVoices / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
)
})
await new Promise<void>((resolve) => setImmediate(resolve))
}
for (const update of voiceIndexUpdates.values()) {
try {
await voiceRecognition?.publishTranscript?.(
update.reference,
update.transcript,
update.cached
)
} catch (error) {
console.warn('[Export] voice transcript index refresh failed:', error)
}
}
} else if (request.includeMedia) {
for (const message of messages) {
@@ -1500,7 +1587,8 @@ async function runSingleExport(
async function runAllExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
): Promise<ExportResult> {
jobs.add(request.jobId)
const send = (progress: ExportJobProgress): void => {
@@ -1519,7 +1607,7 @@ async function runAllExport(
}
const outputFolder = safeFilePart(request.outputName)
const exportRoot = await resolveDefaultExportRoot(outputFolder)
const exportRoot = request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
outputDir = join(exportRoot, outputFolder)
const folderNames = conversationFolderNames(targets)
let lastProgressAt = 0
@@ -1674,7 +1762,8 @@ async function runAllExport(
export async function runExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
): Promise<ExportResult> {
return request.scope === 'all'
? runAllExport(request, win, voiceRecognition)
+296 -30
View File
@@ -6,21 +6,23 @@ import {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportMetadata,
GroupReportRenderSnapshot,
GroupReportRenderSnapshotExportRequest,
ReportHeat,
ReportSectionMeta,
selectHeroParticipantNames
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
import { imageInsightService } from './services/image-insight-service'
import { getReportTemplate } from '../shared/report-templates'
const TEMPLATE_FILES: Record<string, string> = {
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
v1: 'mobile_daily_report_v1.html',
v2: 'mobile_daily_report_v2.html'
}
const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
const templatePath = (templateId?: string): string => {
const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE
const name = LEGACY_TEMPLATE_FILES[templateId || ''] || getReportTemplate(templateId).resourceFile
const candidates = [
path.join(process.resourcesPath, 'resources', name),
path.join(app.getAppPath(), 'resources', name),
@@ -168,6 +170,7 @@ const overflowNote = (
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request
const template = getReportTemplate(request.templateId)
const avatarNames = new Set<string>(metadata.heroParticipants)
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
@@ -175,7 +178,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
quote.messages.forEach((message) => avatarNames.add(message.sender))
)
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
@@ -341,20 +343,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
.join('')
const galleryCards = (report.media?.gallery || [])
.map(
(item) => `<div class="gallery-card">
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
<div class="gallery-body">
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
<div class="important-text">${escapeHtml(item.note)}</div>
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
</div>
</div>`
)
.join('')
// AI 图片理解结果板块(ImageInsight)
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
const visionCards = (report.media?.visionGallery || [])
@@ -411,11 +399,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
.join('')
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
const heatBarsHtml = report.analytics.topSpeakers
.slice(0, 8)
const heatSpeakers = report.analytics.topSpeakers.slice(0, 8)
const maxSpeakerCount = Math.max(1, ...heatSpeakers.map((speaker) => Math.max(0, speaker.count)))
const heatBarsHtml = heatSpeakers
.map((speaker) => {
const count = Math.max(0, speaker.count)
const width = Math.min(100, count * 12)
const width = Math.max(4, Math.round((count / maxSpeakerCount) * 100))
return `<div class="heat-row">
<span class="heat-name">${escapeHtml(speaker.name)}</span>
<span class="heat-bar"><i style="width:${width}%"></i></span>
@@ -454,7 +443,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
const values: Record<string, string> = {
TEMPLATE_CLASS: template.cssClass,
TEMPLATE_LABEL: escapeHtml(template.label),
TEMPLATE_NAME: escapeHtml(template.name),
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
REPORT_DATE: escapeHtml(metadata.reportDate),
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
@@ -525,9 +518,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
),
VISION_CARDS: visionCards,
VISION_TITLE: '📸 AI 识别的图片精选',
GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
GALLERY_CARDS: galleryCards,
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
@@ -560,11 +550,242 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
return html
}
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
const renderReportSnapshotHtml = async (
request: GroupReportRenderSnapshotExportRequest
): Promise<string> => {
const template = getReportTemplate(request.templateId)
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
const values = {
...request.snapshot.values,
TEMPLATE_CLASS: template.cssClass,
TEMPLATE_LABEL: escapeHtml(template.label),
TEMPLATE_NAME: escapeHtml(template.name),
REPORT_TITLE:
request.snapshot.values.REPORT_TITLE || escapeHtml(`${request.snapshot.groupName}日报`),
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
return html.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
}
export const extractGroupReportRenderSnapshot = async (
htmlPath: string,
fallback: {
groupName: string
reportDate: string
dateRange: string
messageCount: number
generatedAt: string
}
): Promise<GroupReportRenderSnapshot> => {
let reportWindow: BrowserWindow | null = null
try {
reportWindow = new BrowserWindow({
show: false,
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
await reportWindow.loadFile(htmlPath)
const extracted = (await reportWindow.webContents.executeJavaScript(`(() => {
const one = (selector) => document.querySelector(selector)
const text = (...selectors) => {
for (const selector of selectors) {
const value = one(selector)?.textContent?.trim()
if (value) return value
}
return ''
}
const html = (...selectors) => {
for (const selector of selectors) {
const value = one(selector)?.innerHTML
if (value?.trim()) return value
}
return ''
}
const sectionChildren = (selector) => {
const section = one(selector)
if (!section) return ''
return Array.from(section.children)
.filter((child) => !child.classList.contains('section-title'))
.map((child) => child.outerHTML)
.join('')
}
const sectionClass = (...selectors) => {
for (const selector of selectors) {
const section = one(selector)
if (!section) continue
return section.classList.contains('empty-section') || !sectionChildren(selector).trim()
? 'empty-section'
: ''
}
return 'empty-section'
}
const statValues = Array.from(document.querySelectorAll('.hero .stat b, .report-stats .stat-block strong'))
.map((node) => node.textContent?.trim() || '')
const activity = Array.from(document.querySelectorAll('.analytics > .card, .section-analytics-heat .card'))
.find((node) => node.textContent?.includes('活跃时间线'))
?.textContent?.replace(/^.*?活跃时间线[:]?/, '')
.trim() || ''
const legacyRanks = Array.from(document.querySelectorAll('.analytics .rank'))
.map((node) => node.outerHTML)
.join('')
const legacyHeat = Array.from(document.querySelectorAll('.analytics > .heat-row'))
.map((node) => node.outerHTML)
.join('')
const footerText = text('.footer', '.report-footer')
.replaceAll('\\n', ' ')
.replaceAll('\\r', ' ')
.replaceAll('\\t', ' ')
return {
reportTitle: text('.hero h1', '.report-masthead h1', 'title'),
reportDate: text('.report-date strong'),
overview: text('.overview', '.report-lede'),
recordNote: text('.record-note'),
heroAvatars: html('.avatar-grid', '.report-hero-avatars'),
messageCount: statValues[0] || '',
activeUsers: statValues[1] || '',
timeSpan: statValues[2] || '',
topicCount: statValues[3] || '',
topicCards: sectionChildren('.topics') || html('.section-topics .topics-grid'),
importantMessages: sectionChildren('.messages') || html('.section-messages .section-body'),
quoteBlocks: sectionChildren('.quotes') || html('.section-quotes .section-body'),
qaCards: sectionChildren('.qa') || html('.section-qa .section-body'),
resourceItems: sectionChildren('.resources') || html('.section-resources .section-body'),
visionCards: sectionChildren('.vision') || html('.section-vision .vision-grid'),
rankItems: legacyRanks || html('.section-analytics-rank .rank-list'),
heatBars: legacyHeat || html('.section-analytics-heat .section-body'),
activityTimeline: activity,
cloudTags: html('.cloud-tags', '.section-keywords .cloud-tags'),
footerText,
classes: {
topics: sectionClass('.topics', '.section-topics'),
messages: sectionClass('.messages', '.section-messages'),
quotes: sectionClass('.quotes', '.section-quotes'),
qa: sectionClass('.qa', '.section-qa'),
resources: sectionClass('.resources', '.section-resources'),
vision: sectionClass('.vision', '.section-vision'),
keywords: sectionClass('.cloud', '.section-keywords')
}
}
})()`)) as {
reportTitle: string
reportDate: string
overview: string
recordNote: string
heroAvatars: string
messageCount: string
activeUsers: string
timeSpan: string
topicCount: string
topicCards: string
importantMessages: string
quoteBlocks: string
qaCards: string
resourceItems: string
visionCards: string
rankItems: string
heatBars: string
activityTimeline: string
cloudTags: string
footerText: string
classes: Record<string, string>
}
if (!extracted.reportTitle || !extracted.topicCards) {
throw new Error('旧日报 HTML 缺少可迁移的标题或主题内容')
}
const values: Record<string, string> = {
REPORT_TITLE: escapeHtml(extracted.reportTitle),
REPORT_DATE: escapeHtml(extracted.reportDate || fallback.reportDate),
DATE_RANGE: escapeHtml(fallback.dateRange),
TIME_SPAN: escapeHtml(extracted.timeSpan),
HERO_SUMMARY: escapeHtml(extracted.overview),
HERO_TAKEAWAY: '',
HERO_PENDING: '',
HERO_STATUS_LINE: '',
HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section',
HERO_PENDING_EMPTY_CLASS: 'empty-section',
HERO_STATUS_EMPTY_CLASS: 'empty-section',
HERO_AVATARS: extracted.heroAvatars,
HERO_AVATAR_CLASS: extracted.heroAvatars ? '' : 'empty-section',
MESSAGE_COUNT: escapeHtml(extracted.messageCount || String(fallback.messageCount)),
ACTIVE_USERS: escapeHtml(extracted.activeUsers),
TOPIC_COUNT: escapeHtml(extracted.topicCount),
RECORD_NOTE: escapeHtml(extracted.recordNote),
GENERATED_AT: escapeHtml(fallback.generatedAt),
FOOTER_NOTE: escapeHtml(extracted.footerText),
TOPIC_CARDS: extracted.topicCards,
IMPORTANT_MESSAGES: extracted.importantMessages,
QUOTE_BLOCKS: extracted.quoteBlocks,
QA_CARDS: extracted.qaCards,
RESOURCE_ITEMS: extracted.resourceItems,
VISION_TITLE: '📸 AI 识别的图片精选',
VISION_CARDS: extracted.visionCards,
RANK_ITEMS: extracted.rankItems,
HEAT_BARS: extracted.heatBars,
ACTIVITY_TIMELINE: escapeHtml(extracted.activityTimeline),
CLOUD_TAGS: extracted.cloudTags,
TOPICS_EMPTY_CLASS: extracted.classes.topics,
MESSAGES_EMPTY_CLASS: extracted.classes.messages,
QUOTES_EMPTY_CLASS: extracted.classes.quotes,
QA_EMPTY_CLASS: extracted.classes.qa,
RESOURCES_EMPTY_CLASS: extracted.classes.resources,
VISION_EMPTY_CLASS: extracted.classes.vision,
KEYWORDS_EMPTY_CLASS: extracted.classes.keywords,
ANALYTICS_EMPTY_CLASS: extracted.heatBars || extracted.rankItems ? '' : 'empty-section',
ACTIONS_EMPTY_CLASS: 'empty-section',
STORYLINES_EMPTY_CLASS: 'empty-section',
REVERSALS_EMPTY_CLASS: 'empty-section',
CHAINS_EMPTY_CLASS: 'empty-section',
VOICE_EMPTY_CLASS: 'empty-section',
VOICE_RANK_EMPTY_CLASS: 'empty-section',
BADGES_EMPTY_CLASS: 'empty-section',
TODO_CARDS: '',
UNRESOLVED_CARDS: '',
STORYLINE_CARDS: '',
REVERSAL_CARDS: '',
CHAIN_CARDS: '',
VOICE_CARDS: '',
VOICE_RANK_CARDS: '',
BADGE_CARDS: '',
TOPICS_MORE_NOTE: '',
MESSAGES_MORE_NOTE: '',
QUOTES_MORE_NOTE: '',
QA_MORE_NOTE: '',
RESOURCES_MORE_NOTE: '',
ACTIONS_MORE_NOTE: '',
STORYLINES_MORE_NOTE: '',
REVERSALS_MORE_NOTE: '',
CHAINS_MORE_NOTE: '',
VOICE_MORE_NOTE: '',
BADGES_MORE_NOTE: '',
KEYWORDS_MORE_NOTE: ''
}
return {
groupName: fallback.groupName,
reportDate: extracted.reportDate || fallback.reportDate,
values
}
} finally {
if (reportWindow && !reportWindow.isDestroyed()) reportWindow.destroy()
}
}
const captureFullPage = async (
htmlPath: string,
pngPath: string,
templateId?: string
): Promise<string> => {
const template = getReportTemplate(templateId)
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
console.log(`[GroupReport] capture begin html=${htmlPath}`)
const reportWindow = new BrowserWindow({
show: false,
width: 430,
width: captureWidth,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
@@ -583,10 +804,10 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
])`)
console.log('[GroupReport] capture assets ready')
const metrics = (await reportWindow.webContents.executeJavaScript(`({
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, ${captureWidth})),
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
})`)) as { width: number; height: number }
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width)))
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
reportWindow.setContentSize(width, height)
await new Promise((resolve) => setTimeout(resolve, 100))
@@ -611,7 +832,12 @@ export const exportGroupReport = async (
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
const templateLabel =
request.templateId === 'v1'
? '经典版'
: request.templateId === 'v2'
? '丰富版'
: getReportTemplate(request.templateId).fileLabel
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
@@ -620,7 +846,7 @@ export const exportGroupReport = async (
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
const pngEndedAt = new Date()
return {
success: true,
@@ -646,3 +872,43 @@ export const exportGroupReport = async (
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export const exportGroupReportSnapshot = async (
request: GroupReportRenderSnapshotExportRequest
): Promise<GroupReportExportResult> => {
try {
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const templateLabel = getReportTemplate(request.templateId).fileLabel
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
const html = await renderReportSnapshotHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
const pngEndedAt = new Date()
return {
success: true,
htmlPath,
pngPath,
imageDataUrl,
exportTimings: {
html: {
startedAt: htmlStartedAt.toISOString(),
endedAt: htmlEndedAt.toISOString(),
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
},
png: {
startedAt: pngStartedAt.toISOString(),
endedAt: pngEndedAt.toISOString(),
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
}
}
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
+56 -4
View File
@@ -32,14 +32,27 @@ import {
inspectImageDecoderStatus,
type DecodedImage
} from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import {
exportGroupReport,
exportGroupReportSnapshot,
extractGroupReportRenderSnapshot
} from './group-report-service'
import {
deleteGeneratedReport,
listGeneratedReports,
saveGeneratedReport
prepareGeneratedReportTemplateSwitch,
saveGeneratedReport,
updateGeneratedReportTemplate
} from './report-history-service'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
import type {
GroupReportExportRequest,
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import type {
SaveGeneratedReportRequest,
PrepareGeneratedReportTemplateSwitchRequest,
UpdateGeneratedReportTemplateRequest
} from '../shared/report-history'
import type {
AIChatRequestOptions,
AiSearchExternalAuthorizationRequest,
@@ -122,6 +135,12 @@ import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
import { runFirstLaunchMigration } from './app-data-migration'
import { WechatShareConfigStore } from './wechat-share-config-store'
import { WechatShareCardService } from './wechat-share-card-service'
import type {
PublishWechatShareCardRequest,
WechatShareServiceConfig
} from '../shared/wechat-share-card'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -141,6 +160,8 @@ const imageKeyConfigService = new ImageKeyConfigService()
const aiProviderService = new AIProviderService()
const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin()
const wechatShareConfigStore = new WechatShareConfigStore()
const wechatShareCardService = new WechatShareCardService(wechatShareConfigStore)
let tray: Tray | null = null
let recallArchiveMonitor: RecallArchiveMonitor | null = null
let recallProtectionGeneration = 0
@@ -1096,6 +1117,7 @@ app.whenReady().then(async () => {
ipcMain.handle('ai:listProviders', () => aiProviderService.list())
ipcMain.handle('ai:getRuntimeConfig', () => aiProviderService.getRuntimeConfig())
ipcMain.handle('ai:getVisionRuntimeConfig', () => aiProviderService.getVisionRuntimeConfig())
ipcMain.handle('ai:saveProvider', (_, provider: AIProviderConfig) =>
aiProviderService.save(provider)
)
@@ -1132,12 +1154,21 @@ app.whenReady().then(async () => {
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request)
})
ipcMain.handle(
'report:exportSnapshot',
async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request)
)
ipcMain.handle('export:start', async (event, request: ExportRequest) => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) return { success: false, error: '窗口不可用' }
return runExport(request, window, voiceRecognition || undefined)
})
ipcMain.handle('export:selectDirectory', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, { properties: ['openDirectory', 'createDirectory'] })
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
ipcMain.handle('export:cancel', (_, jobId: string) => {
cancelExport(jobId)
return { success: true }
@@ -1158,6 +1189,19 @@ app.whenReady().then(async () => {
return saveGeneratedReport(request)
})
ipcMain.handle(
'report:updateGeneratedTemplate',
async (_, request: UpdateGeneratedReportTemplateRequest) => {
return updateGeneratedReportTemplate(request)
}
)
ipcMain.handle(
'report:prepareTemplateSwitch',
async (_, request: PrepareGeneratedReportTemplateSwitchRequest) =>
prepareGeneratedReportTemplateSwitch(request.reportId, extractGroupReportRenderSnapshot)
)
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
return deleteGeneratedReport(reportId)
})
@@ -1171,6 +1215,14 @@ app.whenReady().then(async () => {
}
})
ipcMain.handle('wechat-share:getConfig', async () => wechatShareConfigStore.status())
ipcMain.handle('wechat-share:saveConfig', async (_, config: WechatShareServiceConfig) =>
wechatShareConfigStore.save(config)
)
ipcMain.handle('wechat-share:publish', async (_, request: PublishWechatShareCardRequest) =>
wechatShareCardService.publish(request)
)
ipcMain.handle(
'db:getVoiceData',
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
+2 -2
View File
@@ -87,7 +87,7 @@ export class KeyServiceMac {
code,
error:
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
'建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
}
}
@@ -141,7 +141,7 @@ export class KeyServiceMac {
'return "ERR::" & errNum & "::" & errMsg',
'end try'
]
onStatus?.('授权后请保持微信登录界面 点击登录微信...')
onStatus?.('授权后 需要在微信登录界面 点击登录微信')
const { stdout } = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap((line) => ['-e', line]),
+1 -1
View File
@@ -683,7 +683,7 @@ export class KeyService {
if (loginRequired) {
return {
success: false,
error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。',
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
logs
}
}
+129 -2
View File
@@ -7,8 +7,11 @@ import type {
ReportAssetStatus,
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
SaveGeneratedReportResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../shared/report-history'
import type { GroupReportRenderSnapshot } from '../shared/group-report'
const REPORTS_DIR = 'reports'
@@ -78,6 +81,7 @@ const normalizeRecord = async (
const pngStatus = await fileStatus(record.pngPath)
return {
...record,
textModelName: record.textModelName || record.modelName,
jsonPath,
htmlStatus,
pngStatus,
@@ -178,13 +182,18 @@ export async function saveGeneratedReport(
pngStatus: savedPngPath ? 'ready' : 'missing',
imageSize: await readPngSize(savedPngPath),
duration: request.duration,
textModelName: request.textModelName || request.modelName,
imageModelName: request.imageModelName,
modelName: request.modelName,
tokenUsage: request.tokenUsage,
fileSize: {
html: await readFileSize(savedHtmlPath),
png: await readFileSize(savedPngPath)
},
generationLogs: request.generationLogs
generationLogs: request.generationLogs,
reportSnapshot: request.reportSnapshot,
reportMetadata: request.reportMetadata,
templateId: request.templateId
}
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
@@ -200,6 +209,124 @@ export async function saveGeneratedReport(
}
}
export async function updateGeneratedReportTemplate(
request: UpdateGeneratedReportTemplateRequest
): Promise<UpdateGeneratedReportTemplateResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) {
try {
const content = await fs.readFile(jsonPath, 'utf8')
const record = JSON.parse(content) as GeneratedReportRecord
if (record.id !== request.reportId) continue
if ((!record.reportSnapshot || !record.reportMetadata) && !record.reportRenderSnapshot) {
return { success: false, error: '旧报告未保存结构化数据,无法无损切换模板' }
}
const directory = path.dirname(jsonPath)
const baseName = path.basename(jsonPath, '.json')
const htmlPath = record.htmlPath || path.join(directory, `${baseName}.html`)
const pngPath = record.pngPath || path.join(directory, `${baseName}.png`)
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
const hasPngFile = Boolean(request.pngPath && (await exists(request.pngPath)))
if (!request.htmlPath || !(await exists(request.htmlPath))) {
return { success: false, error: '新模板 HTML 文件不存在' }
}
if (!imageBuffer && !hasPngFile) {
return { success: false, error: '新模板 PNG 文件不存在' }
}
await fs.copyFile(request.htmlPath, htmlPath)
if (imageBuffer) {
await fs.writeFile(pngPath, imageBuffer)
} else if (request.pngPath) {
await fs.copyFile(request.pngPath, pngPath)
}
const updated: GeneratedReportRecord = {
...record,
htmlPath,
pngPath,
jsonPath,
htmlStatus: 'ready',
pngStatus: 'ready',
imageSize: await readPngSize(pngPath),
fileSize: {
html: await readFileSize(htmlPath),
png: await readFileSize(pngPath)
},
templateId: request.templateId
}
delete updated.generatedImage
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
return {
success: true,
record: {
...updated,
generatedImage: await readPngAsDataUrl(pngPath)
}
}
} catch (error) {
console.warn(
`[ReportHistory] skip invalid report record while switching template: ${jsonPath}`,
error
)
}
}
return { success: false, error: '未找到要切换模板的日报记录' }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function prepareGeneratedReportTemplateSwitch(
reportId: string,
extractSnapshot: (
htmlPath: string,
fallback: {
groupName: string
reportDate: string
dateRange: string
messageCount: number
generatedAt: string
}
) => Promise<GroupReportRenderSnapshot>
): Promise<import('../shared/report-history').PrepareGeneratedReportTemplateSwitchResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) {
try {
const content = await fs.readFile(jsonPath, 'utf8')
const record = JSON.parse(content) as GeneratedReportRecord
if (record.id !== reportId) continue
if (record.reportRenderSnapshot)
return { success: true, snapshot: record.reportRenderSnapshot }
if (!record.htmlPath || !(await exists(record.htmlPath))) {
return { success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' }
}
const snapshot = await extractSnapshot(record.htmlPath, {
groupName: record.contactName,
reportDate: record.reportDate,
dateRange: record.dateRange,
messageCount: record.messageCount,
generatedAt: record.generatedAt
})
const updated = { ...record, reportRenderSnapshot: snapshot }
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
return { success: true, snapshot }
} catch (error) {
console.warn(
`[ReportHistory] skip invalid report record while preparing template switch: ${jsonPath}`,
error
)
}
}
return { success: false, error: '未找到要切换模板的日报记录' }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function deleteGeneratedReport(
reportId: string
): Promise<DeleteGeneratedReportResult> {
+55
View File
@@ -9,6 +9,7 @@ import type {
AIProviderSummary,
AiSearchProviderStatus,
AIRuntimeModelConfig,
AIVisionRuntimeConfig,
AIVisionTestRequest,
AIVisionTestResult,
LegacyAIConfig
@@ -74,6 +75,60 @@ export class AIProviderService {
}
}
getVisionRuntimeConfig(): AIVisionRuntimeConfig {
const result = this.list()
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
const defaultModel = defaultProvider?.models.find(
(item) => item.id === defaultProvider.defaultModel
)
if (
defaultProvider &&
defaultModel &&
(defaultProvider.hasApiKey || !needsApiKey(defaultProvider)) &&
(defaultModel.capabilities.vision || defaultModel.capabilities.ocr)
) {
return {
providerId: defaultProvider.id,
providerName: defaultProvider.name,
model: defaultModel.id,
modelName: defaultModel.name || defaultModel.id,
configured: true,
status: defaultProvider.status,
timeoutMs: defaultProvider.advanced.timeoutMs,
source: 'default-model'
}
}
for (const provider of result.providers) {
if (!provider.hasApiKey && needsApiKey(provider)) continue
const model =
provider.models.find(
(item) =>
item.id === provider.defaultModel && (item.capabilities.vision || item.capabilities.ocr)
) || provider.models.find((item) => item.capabilities.vision || item.capabilities.ocr)
if (!model) continue
return {
providerId: provider.id,
providerName: provider.name,
model: model.id,
modelName: model.name || model.id,
configured: true,
status: provider.status,
timeoutMs: provider.advanced.timeoutMs,
source: 'vision-capability'
}
}
return {
providerName: '尚未配置',
model: '',
modelName: '尚未验证图片理解模型',
configured: false,
status: 'untested',
source: 'unavailable'
}
}
getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus {
const result = this.list()
const provider =
+58 -20
View File
@@ -3,9 +3,9 @@
//
// 设计原则:
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
// 2. 同图(imageHash)走缓存,绝不重复调 AI
// 2. 同图(imageHash)在 10 分钟内走缓存,过期后重新调 AI
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
import crypto from 'crypto'
import { randomUUID } from 'crypto'
@@ -22,6 +22,11 @@ import type {
ImageCandidateQuery,
ImageInsight
} from '../../shared/image-insight'
import {
calculateImageHeatScore,
isFreshImageInsight,
isHotImageCandidate
} from '../../shared/image-insight'
/**
* ( renderer messages main)
@@ -42,6 +47,13 @@ export interface ImageCandidateInput {
interface ProviderServiceLike {
list(): ProviderSummaryLike
getVisionRuntimeConfig(): {
providerId?: string
providerName: string
model: string
modelName: string
configured: boolean
}
analyzeImage(
messages: Array<{
role: string
@@ -77,7 +89,7 @@ interface ProviderSummaryLike {
class ImageInsightService {
private providerService: ProviderServiceLike | null = null
private decryptService: DecryptServiceLike | null = null
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
/** 最近一次实际使用的视觉 provider/model,仅用于写入分析元数据 */
private runtimeProviderId: string | undefined = undefined
private runtimeModelId: string | undefined = undefined
@@ -93,13 +105,11 @@ class ImageInsightService {
this.runtimeProviderId,
this.runtimeModelId
)
// 读取默认 provider/model(后续 analyze 时使用)
// 读取当前视觉 provider/model(后续 analyze 时仍会刷新,避免配置变化后继续用旧模型)
try {
const list = deps.providerService.list()
const provider =
list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0]
this.runtimeProviderId = provider?.id
this.runtimeModelId = provider?.defaultModel
const runtime = deps.providerService.getVisionRuntimeConfig()
this.runtimeProviderId = runtime.providerId
this.runtimeModelId = runtime.model || undefined
console.log(
'[ImageInsightService] bind loaded default provider=%s model=%s',
this.runtimeProviderId,
@@ -141,7 +151,7 @@ class ImageInsightService {
/**
* 主入口:分析一张图片
* 1. imageHash ,
* 1. imageHash 10 ,
* 2. 未命中:解密图片 AI
* 3. 任意步骤失败:记录日志, success=false,****
*/
@@ -149,8 +159,15 @@ class ImageInsightService {
try {
if (!request.force) {
const cached = imageInsightsStore.getByHash(request.imageHash)
if (isFreshImageInsight(cached)) {
return { success: true, insight: cached || undefined, fromCache: true }
}
if (cached) {
return { success: true, insight: cached, fromCache: true }
console.log(
'[ImageInsightService] cache expired hash=%s ageMs=%d',
request.imageHash,
Date.now() - Number(cached.updatedAt || 0)
)
}
}
@@ -180,11 +197,24 @@ class ImageInsightService {
}
]
const list = this.providerService.list()
const provider =
list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0]
this.runtimeProviderId = provider?.id
this.runtimeModelId = provider?.defaultModel
const runtime =
request.providerId && request.modelId
? {
providerId: request.providerId,
model: request.modelId,
configured: true
}
: this.providerService.getVisionRuntimeConfig()
if (!runtime.configured || !runtime.providerId || !runtime.model) {
return { success: false, error: '尚未配置或验证支持图片理解的 AI 模型' }
}
this.runtimeProviderId = runtime.providerId
this.runtimeModelId = runtime.model
console.log(
'[ImageInsightService] analyze using vision provider=%s model=%s',
this.runtimeProviderId,
this.runtimeModelId
)
const result = await this.providerService.analyzeImage(messages, {
providerId: this.runtimeProviderId,
modelId: this.runtimeModelId
@@ -234,7 +264,7 @@ class ImageInsightService {
query: ImageCandidateQuery,
inputs: ImageCandidateInput[] = []
): Promise<ImageCandidate[]> {
const limit = query.limit ?? 3
const limit = Math.min(3, Math.max(0, query.limit ?? 3))
const candidates: ImageCandidate[] = []
console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length)
for (const input of inputs) {
@@ -248,7 +278,16 @@ class ImageInsightService {
)
continue
}
const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1
if (!isHotImageCandidate(input)) {
console.log(
'[ImageInsightService] skip %s: not hot (responses=%d interactions=%d)',
input.messageId,
input.responseCount,
input.interactionCount
)
continue
}
const heatScore = calculateImageHeatScore(input)
const candidate: ImageCandidate = {
messageId: input.messageId,
imageHash: hash,
@@ -260,7 +299,7 @@ class ImageInsightService {
heatScore
}
const cached = imageInsightsStore.getByHash(hash)
if (cached) candidate.insight = cached
if (isFreshImageInsight(cached) && cached) candidate.insight = cached
candidates.push(candidate)
}
candidates.sort((a, b) => b.heatScore - a.heatScore)
@@ -273,7 +312,6 @@ class ImageInsightService {
listBySession(sessionId: string, limit?: number): ImageInsight[] {
return imageInsightsStore.listBySession(sessionId, limit)
}
}
export const imageInsightService = new ImageInsightService()
@@ -10,6 +10,7 @@ export interface PcmProcessorOptions {
}
export class PcmAudioProcessor implements AudioProcessor {
readonly version = VOICE_PROCESSOR_VERSION
private readonly targetSampleRate: number
private readonly silenceThreshold: number
private readonly silencePaddingMs: number
+109 -32
View File
@@ -1,17 +1,40 @@
import { dirname } from 'path'
import { mkdirSync } from 'fs'
import { DatabaseSync } from 'node:sqlite'
import type {
TranscriptMessageStatus,
TranscriptRecord,
TranscriptRepository
} from './types'
import type { TranscriptMessageStatus, TranscriptRecord, TranscriptRepository } from './types'
type TranscriptKey = Omit<
TranscriptRecord,
'transcript' | 'language' | 'durationMs' | 'createdAt' | 'updatedAt'
>
type CompatibleTranscriptKey = Pick<
TranscriptRecord,
| 'accountId'
| 'messageIdentity'
| 'processorVersion'
| 'recognizerId'
| 'modelVersion'
| 'modelFingerprint'
>
function transcriptRecord(row: Record<string, unknown>): TranscriptRecord {
return {
accountId: String(row.account_id),
messageIdentity: String(row.message_identity),
audioHash: String(row.audio_hash),
processorVersion: String(row.processor_version),
recognizerId: String(row.recognizer_id),
modelVersion: String(row.model_version),
modelFingerprint: String(row.model_fingerprint),
transcript: String(row.transcript),
language: row.language ? String(row.language) : undefined,
durationMs: Number(row.duration_ms),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at)
}
}
export class SqliteTranscriptRepository implements TranscriptRepository {
private readonly database: DatabaseSync
@@ -70,20 +93,7 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
key.modelFingerprint
) as Record<string, unknown> | undefined
if (!row) return null
return {
accountId: String(row.account_id),
messageIdentity: String(row.message_identity),
audioHash: String(row.audio_hash),
processorVersion: String(row.processor_version),
recognizerId: String(row.recognizer_id),
modelVersion: String(row.model_version),
modelFingerprint: String(row.model_fingerprint),
transcript: String(row.transcript),
language: row.language ? String(row.language) : undefined,
durationMs: Number(row.duration_ms),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at)
}
return transcriptRecord(row)
}
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null {
@@ -99,19 +109,86 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
)
.get(accountId, messageIdentity) as Record<string, unknown> | undefined
if (!row) return null
return {
accountId: String(row.account_id),
messageIdentity: String(row.message_identity),
audioHash: String(row.audio_hash),
processorVersion: String(row.processor_version),
recognizerId: String(row.recognizer_id),
modelVersion: String(row.model_version),
modelFingerprint: String(row.model_fingerprint),
transcript: String(row.transcript),
language: row.language ? String(row.language) : undefined,
durationMs: Number(row.duration_ms),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at)
return transcriptRecord(row)
}
findCompatible(key: CompatibleTranscriptKey): TranscriptRecord | null {
const row = this.database
.prepare(
`SELECT account_id, message_identity, audio_hash, processor_version,
recognizer_id, model_version, model_fingerprint, transcript,
language, duration_ms, created_at, updated_at
FROM voice_transcripts
WHERE account_id = ? AND message_identity = ? AND processor_version = ?
AND recognizer_id = ? AND model_version = ? AND model_fingerprint = ?
AND trim(transcript) <> ''
ORDER BY updated_at DESC
LIMIT 1`
)
.get(
key.accountId,
key.messageIdentity,
key.processorVersion,
key.recognizerId,
key.modelVersion,
key.modelFingerprint
) as Record<string, unknown> | undefined
return row ? transcriptRecord(row) : null
}
mergeFrom(databasePath: string): number {
this.database.prepare('ATTACH DATABASE ? AS legacy_voice').run(databasePath)
try {
const hasTranscripts = this.database
.prepare(
`SELECT 1 FROM legacy_voice.sqlite_master
WHERE type = 'table' AND name = 'voice_transcripts'`
)
.get()
if (!hasTranscripts)
throw new Error('Legacy voice transcript database has no transcript table')
this.database.exec('BEGIN IMMEDIATE')
try {
this.database.exec(`
INSERT OR IGNORE INTO main.voice_transcripts (
account_id, message_identity, audio_hash, processor_version,
recognizer_id, model_version, model_fingerprint, transcript,
language, duration_ms, created_at, updated_at
)
SELECT account_id, message_identity, audio_hash, processor_version,
recognizer_id, model_version, model_fingerprint, transcript,
language, duration_ms, created_at, updated_at
FROM legacy_voice.voice_transcripts
WHERE trim(transcript) <> ''
`)
const inserted = Number(
(
this.database.prepare('SELECT changes() AS count').get() as
| Record<string, unknown>
| undefined
)?.count || 0
)
this.database.exec(`
INSERT INTO main.voice_transcript_message_states (
account_id, message_identity, state, error, updated_at
)
SELECT account_id, message_identity, 'transcribed', NULL, MAX(updated_at)
FROM legacy_voice.voice_transcripts
WHERE trim(transcript) <> ''
GROUP BY account_id, message_identity
ON CONFLICT (account_id, message_identity) DO UPDATE SET
state = 'transcribed', error = NULL, updated_at = excluded.updated_at
WHERE excluded.updated_at > voice_transcript_message_states.updated_at
`)
this.database.exec('COMMIT')
return inserted
} catch (error) {
this.database.exec('ROLLBACK')
throw error
}
} finally {
this.database.exec('DETACH DATABASE legacy_voice')
}
}
+12
View File
@@ -43,6 +43,7 @@ export class SpeechRecognizerRegistry {
}
export interface AudioProcessor {
readonly version: string
process(input: {
pcm: Buffer
sampleRate: number
@@ -87,6 +88,17 @@ export interface TranscriptRepository {
>
): TranscriptRecord | null
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null
findCompatible(
key: Pick<
TranscriptRecord,
| 'accountId'
| 'messageIdentity'
| 'processorVersion'
| 'recognizerId'
| 'modelVersion'
| 'modelFingerprint'
>
): TranscriptRecord | null
getMessageStatus(accountId: string, messageIdentity: string): TranscriptMessageStatus
save(record: TranscriptRecord): void
markFailure(accountId: string, messageIdentity: string, error: string): void
+16 -1
View File
@@ -39,13 +39,28 @@ export class VoicePipeline {
reference: VoiceMessageReference,
signal?: AbortSignal
): Promise<{ transcript: string; language?: string; durationMs: number; cached: boolean }> {
const messageIdentity = voiceMessageIdentity(reference)
const compatible = this.transcripts.findCompatible({
accountId,
messageIdentity,
processorVersion: this.audioProcessor.version,
...this.recognizer.metadata
})
if (compatible?.transcript.trim()) {
return {
transcript: compatible.transcript.trim(),
language: compatible.language,
durationMs: compatible.durationMs,
cached: true
}
}
const source = await this.sourceResolver.resolve(reference)
if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError')
const decoded = await this.decoderRegistry.decode(source)
if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError')
const audio = this.audioProcessor.process(decoded)
if (audio.samples.length === 0) throw new Error('Voice audio is empty after processing')
const messageIdentity = voiceMessageIdentity(reference)
const key = {
accountId,
messageIdentity,
@@ -97,36 +97,44 @@ export class VoiceRecognitionUseCase {
const generation = this.accountGeneration
const accountIdentity = this.accountId
return this.scheduler
.schedule(key, async (signal) => {
const status = await this.modelManager.getStatus()
if (status.state !== 'ready') {
return { success: false, code: 'MODEL_NOT_READY', error: '请先下载语音识别模型' } as const
}
const result = await pipeline.run(accountId, reference, signal)
if (signal.aborted || !this.isCurrentAccount(accountId, generation)) {
throw new DOMException('Recognition cancelled', 'AbortError')
}
const transcript = result.transcript.trim()
if (
transcript &&
options?.publishTranscriptUpdate !== false &&
this.isCurrentAccount(accountId, generation)
) {
try {
await this.publishTranscriptUpdate({
accountIdentity,
reference,
messageIdentity: voiceMessageIdentity(reference),
state: 'transcribed',
transcript,
cached: result.cached
})
} catch (error) {
console.warn('[Voice] transcript indexed asynchronously failed:', error)
.schedule(
key,
async (signal) => {
const status = await this.modelManager.getStatus()
if (status.state !== 'ready') {
return {
success: false,
code: 'MODEL_NOT_READY',
error: '请先下载语音识别模型'
} as const
}
}
return { success: true, ...result, transcript } as const
}, { priority: options?.priority })
const result = await pipeline.run(accountId, reference, signal)
if (signal.aborted || !this.isCurrentAccount(accountId, generation)) {
throw new DOMException('Recognition cancelled', 'AbortError')
}
const transcript = result.transcript.trim()
if (
transcript &&
options?.publishTranscriptUpdate !== false &&
this.isCurrentAccount(accountId, generation)
) {
try {
await this.publishTranscriptUpdate({
accountIdentity,
reference,
messageIdentity: voiceMessageIdentity(reference),
state: 'transcribed',
transcript,
cached: result.cached
})
} catch (error) {
console.warn('[Voice] transcript indexed asynchronously failed:', error)
}
}
return { success: true, ...result, transcript } as const
},
{ priority: options?.priority }
)
.catch((error): VoiceRecognitionResult => {
if (error instanceof DOMException && error.name === 'AbortError') {
return { success: false, code: 'CANCELLED', error: '语音识别已取消' }
@@ -173,18 +181,25 @@ export class VoiceRecognitionUseCase {
}
async publishTranscriptSnapshot(reference: VoiceMessageReference): Promise<void> {
const accountIdentity = this.accountId
if (!accountIdentity) return
const snapshot = this.getTranscriptSnapshot(reference)
if (snapshot.state === 'pending') return
await this.publishTranscript(reference, snapshot.transcript, snapshot.state === 'transcribed')
}
async publishTranscript(
reference: VoiceMessageReference,
transcript?: string,
cached = true
): Promise<void> {
const accountIdentity = this.accountId
if (!accountIdentity || !transcript?.trim()) return
await this.publishTranscriptUpdate({
accountIdentity,
reference,
messageIdentity: voiceMessageIdentity(reference),
state: snapshot.state,
transcript: snapshot.transcript,
error: snapshot.error,
cached: snapshot.state === 'transcribed'
state: 'transcribed',
transcript: transcript.trim(),
cached
})
}
+84
View File
@@ -27,6 +27,13 @@ export type ResolveSourceResult =
| { success: true; source: EncodedVoiceSource }
| { success: false; error: string }
export interface VoiceReference {
sessionId: string
localId: number
createTime: number
svrId?: string | number
}
export class VoiceService {
private wcdb4Client: Wcdb4Client
private voiceCache = new Map<string, string>()
@@ -70,6 +77,83 @@ export class VoiceService {
return { success: true, data: base64Data }
}
async resolveVoices(
references: VoiceReference[]
): Promise<Array<{ success: boolean; data?: string; error?: string }>> {
const results: Array<{ success: boolean; data?: string; error?: string } | undefined> =
new Array(references.length)
const missing: Array<{ index: number; reference: VoiceReference }> = []
references.forEach((reference, index) => {
const cached = this.voiceCache.get(
this.buildCacheKey(reference.sessionId, reference.localId, reference.createTime)
)
if (cached) results[index] = { success: true, data: cached }
else missing.push({ index, reference })
})
if (!missing.length)
return results as Array<{ success: boolean; data?: string; error?: string }>
const sources = await this.wcdb4Client.getVoiceDataBatch(
missing.map(({ reference }) => ({
sessionId: reference.sessionId,
createTime: reference.createTime,
localId: reference.localId,
svrId: reference.svrId,
candidates: this.buildCandidates(reference.sessionId)
}))
)
const failed: Array<{ index: number; reference: VoiceReference }> = []
for (const [{ index, reference }, source] of missing.map(
(item, sourceIndex) => [item, sources[sourceIndex]] as const
)) {
if (!source?.success || !source.hex) {
failed.push({ index, reference })
continue
}
const silkData = this.decodeVoiceBlob(source.hex)
if (!silkData?.length) {
results[index] = { success: false, error: '语音数据为空' }
continue
}
try {
const decoded = await this.decoderRegistry.decode({
data: silkData,
codec: 'silk',
sourceHash: createHash('sha256').update(silkData).digest('hex')
})
const wavData = this.createWavBuffer(decoded.pcm, 24000)
const data = wavData.toString('base64')
const cacheKey = this.buildCacheKey(
reference.sessionId,
reference.localId,
reference.createTime
)
this.voiceCache.set(cacheKey, data)
this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' })
results[index] = { success: true, data }
} catch (error) {
results[index] = {
success: false,
error: error instanceof Error ? error.message : 'Silk 解码失败'
}
}
}
const retried = await Promise.all(
failed.map(({ reference }) =>
this.resolveVoice(
reference.sessionId,
reference.localId,
reference.createTime,
reference.svrId
)
)
)
failed.forEach(({ index }, retryIndex) => {
results[index] = retried[retryIndex]
})
return results as Array<{ success: boolean; data?: string; error?: string }>
}
async resolvePcm(
sessionId: string,
localId: number,
+112 -17
View File
@@ -250,6 +250,20 @@ type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
type WcdbVoidOut = [unknown]
type WcdbHandleOut = [number]
export interface Wcdb4VoiceDataRequest {
sessionId: string
createTime: number
candidates: string[]
localId?: number
svrId?: string | number
}
export interface Wcdb4VoiceDataResult {
success: boolean
hex?: string
error: string
}
const nodeRequire = createRequire(import.meta.url)
function isAsciiPath(value: string): boolean {
@@ -409,6 +423,7 @@ export class Wcdb4Client {
outHex: WcdbVoidOut
) => number)
| null = null
private wcdbGetVoiceDataBatch: KoffiAsyncFunction | null = null
private wcdbResolveImageHardlink:
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
| null = null
@@ -1953,32 +1968,104 @@ export class Wcdb4Client {
}
const handle = this.ensureHandle()
const outHex: WcdbVoidOut = [null]
try {
const result = this.wcdbGetVoiceData(
const fn = this.wcdbGetVoiceData as unknown as KoffiAsyncFunction
return this.createTrackedNativeCall<Wcdb4VoiceDataResult>((resolve, reject) => {
const outHex: WcdbVoidOut = [null]
fn.async(
handle,
sessionId,
createTime,
localId,
BigInt(svrId || 0),
JSON.stringify(candidates),
outHex
outHex,
(error: unknown, code: unknown) => {
if (error) {
reject(error)
return
}
try {
const result = Number(code)
if (result !== 0 || !outHex[0]) {
resolve({ success: false, error: `获取语音数据失败: ${result}` })
return
}
const hex = this.decodeHexPtr(outHex[0])
resolve(
hex === null
? { success: false, error: '解析语音数据失败' }
: { success: true, hex, error: '' }
)
} finally {
this.wcdbFreeString?.(outHex[0])
}
}
)
})
}
if (result !== 0 || !outHex[0]) {
return { success: false, error: `获取语音数据失败: ${result}` }
}
const hex = this.decodeHexPtr(outHex[0])
if (hex === null) {
return { success: false, error: '解析语音数据失败' }
}
return { success: true, hex, error: '' }
} finally {
this.wcdbFreeString?.(outHex[0])
async getVoiceDataBatch(requests: Wcdb4VoiceDataRequest[]): Promise<Wcdb4VoiceDataResult[]> {
if (!requests.length) return []
if (!this.wcdbGetVoiceDataBatch) {
return this.getVoiceDataIndividually(requests)
}
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetVoiceDataBatch,
JSON.stringify(
requests.map((request, index) => ({
session_id: request.sessionId,
create_time: request.createTime,
local_id: request.localId || 0,
svr_id: String(request.svrId || 0),
candidates: request.candidates,
index
}))
)
)
const byIndex = new Map(
(Array.isArray(rows) ? rows : []).map((row) => [Number(row.index), row])
)
return requests.map((_, index) => {
const row = byIndex.get(index)
const rawHex = row?.hex ?? row?.data
const rawSuccess = row?.success ?? row?.Success
const hex = typeof rawHex === 'string' ? rawHex : undefined
const success = (rawSuccess === true || Number(rawSuccess) === 1) && Boolean(hex)
return {
success,
hex: success ? hex : undefined,
error:
typeof row?.error === 'string' && row.error
? row.error
: success
? ''
: '获取语音数据失败'
}
})
} catch (error) {
console.warn('[WCDB4] batch voice lookup failed, using single-item fallback:', error)
return this.getVoiceDataIndividually(requests)
}
}
private async getVoiceDataIndividually(
requests: Wcdb4VoiceDataRequest[]
): Promise<Wcdb4VoiceDataResult[]> {
const results: Wcdb4VoiceDataResult[] = []
for (const request of requests) {
results.push(
await this.getVoiceData(
request.sessionId,
request.createTime,
request.candidates,
request.localId,
request.svrId
)
)
}
return results
}
private decodeHexPtr(ptr: unknown): string | null {
@@ -2279,6 +2366,14 @@ export class Wcdb4Client {
this.wcdbGetVoiceData = null
}
try {
this.wcdbGetVoiceDataBatch = lib.func(
'int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)'
)
} catch {
this.wcdbGetVoiceDataBatch = null
}
try {
this.wcdbResolveImageHardlink = lib.func(
'int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)'
+5 -11
View File
@@ -216,17 +216,11 @@ export class WechatDb {
if (!username) return []
if (startTime && endTime && startTime > endTime) return []
// A bounded native cursor can omit rows stored across a message shard boundary.
// Scan without bounds first, then apply the requested range in application code.
const rows = await this.wcdb4Client.getMessagesAsync(username)
const messages = rows
.map((message) => ({ ...message.raw, ...message }))
.filter((message) => {
const createTime = Number(message.msgCreateTime || 0)
if (startTime && createTime < startTime) return false
if (endTime && createTime > endTime) return false
return true
})
// The async client merges cursor and shard-table results while applying the
// time range in SQLite. Avoid loading the entire conversation when exporting
// a narrow range; this is especially important for chats with long history.
const rows = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime)
const messages = rows.map((message) => ({ ...message.raw, ...message }))
console.log(
`[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}`
)
+73
View File
@@ -0,0 +1,73 @@
import { nativeImage } from 'electron'
import fs from 'fs-extra'
import QRCode from 'qrcode'
import type {
PublishWechatShareCardRequest,
PublishWechatShareCardResult
} from '../shared/wechat-share-card'
import { WechatShareConfigStore } from './wechat-share-config-store'
const MAX_REPORT_BYTES = 25 * 1024 * 1024
export class WechatShareCardService {
constructor(private readonly configStore: WechatShareConfigStore) {}
async publish(request: PublishWechatShareCardRequest): Promise<PublishWechatShareCardResult> {
try {
const config = await this.configStore.loadRaw()
if (!config) return { success: false, error: '请先配置微信卡片服务' }
const png = await fs.readFile(request.pngPath)
if (!png.length) return { success: false, error: '日报图片为空' }
if (png.length > MAX_REPORT_BYTES) return { success: false, error: '日报图片不能超过 25 MB' }
const source = nativeImage.createFromBuffer(png)
if (source.isEmpty()) return { success: false, error: '无法读取日报图片' }
const size = source.getSize()
const squareSize = Math.min(size.width, size.height)
const thumbnail = source
.crop({ x: 0, y: 0, width: squareSize, height: squareSize })
.resize({ width: 360, height: 360, quality: 'best' })
.toJPEG(84)
const response = await fetch(`${config.serviceUrl}/api/cards`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.uploadToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: request.title.trim().slice(0, 64),
description: request.description.trim().slice(0, 120),
expiresInDays: Math.max(1, Math.min(30, request.expiresInDays || 7)),
imageBase64: png.toString('base64'),
thumbnailBase64: thumbnail.toString('base64')
}),
signal: AbortSignal.timeout(90_000)
})
const payload = (await response.json().catch(() => ({}))) as {
cardId?: string
shareUrl?: string
viewUrl?: string
expiresAt?: string
error?: string
}
if (!response.ok || !payload.shareUrl) {
return { success: false, error: payload.error || `卡片服务返回 HTTP ${response.status}` }
}
return {
success: true,
cardId: payload.cardId,
shareUrl: payload.shareUrl,
viewUrl: payload.viewUrl,
expiresAt: payload.expiresAt,
qrCodeDataUrl: await QRCode.toDataURL(payload.shareUrl, {
width: 360,
margin: 2,
errorCorrectionLevel: 'M'
})
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type {
WechatShareServiceConfig,
WechatShareServiceConfigResult
} from '../shared/wechat-share-card'
const normalizeUrl = (value: string): string => value.trim().replace(/\/+$/, '')
const validate = (config: WechatShareServiceConfig): string | null => {
try {
const url = new URL(normalizeUrl(config.serviceUrl))
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) {
return '卡片服务必须使用 HTTPS'
}
} catch {
return '卡片服务地址无效'
}
if (config.uploadToken.trim().length < 24) return '上传密钥至少需要 24 个字符'
return null
}
export class WechatShareConfigStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'wechat-share-service.bin')
}
async loadRaw(): Promise<WechatShareServiceConfig | null> {
if (!(await fs.pathExists(this.filePath))) return null
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
const encrypted = await fs.readFile(this.filePath)
const parsed = JSON.parse(safeStorage.decryptString(encrypted)) as WechatShareServiceConfig
const error = validate(parsed)
if (error) throw new Error(error)
return { serviceUrl: normalizeUrl(parsed.serviceUrl), uploadToken: parsed.uploadToken.trim() }
}
async status(): Promise<WechatShareServiceConfigResult> {
try {
const config = await this.loadRaw()
return {
success: true,
configured: Boolean(config),
serviceUrl: config?.serviceUrl
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async save(config: WechatShareServiceConfig): Promise<WechatShareServiceConfigResult> {
const normalized = {
serviceUrl: normalizeUrl(config.serviceUrl),
uploadToken: config.uploadToken.trim()
}
const error = validate(normalized)
if (error) return { success: false, error }
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
await fs.ensureDir(path.dirname(this.filePath))
await fs.writeFile(this.filePath, safeStorage.encryptString(JSON.stringify(normalized)), {
mode: 0o600
})
await fs.chmod(this.filePath, 0o600)
return { success: true, configured: true, serviceUrl: normalized.serviceUrl }
}
}
+34 -2
View File
@@ -1,12 +1,19 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types'
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
import {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test'
import {
DeleteGeneratedReportResult,
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
SaveGeneratedReportResult,
PrepareGeneratedReportTemplateSwitchResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../shared/report-history'
import type {
DatabaseKeyEnvironment,
@@ -33,6 +40,7 @@ import type {
AIProviderConfig,
AIProviderListResult,
AIRuntimeModelConfig,
AIVisionRuntimeConfig,
AIVisionTestRequest,
AIVisionTestResult,
LegacyAIConfig
@@ -72,6 +80,12 @@ import type {
KnowledgeSearchIpcRequest,
KnowledgeSearchIpcResult
} from '../shared/knowledge'
import type {
PublishWechatShareCardRequest,
PublishWechatShareCardResult,
WechatShareServiceConfig,
WechatShareServiceConfigResult
} from '../shared/wechat-share-card'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -225,6 +239,7 @@ declare global {
}>
listAIProviders: () => Promise<AIProviderListResult>
getAIRuntimeConfig: () => Promise<AIRuntimeModelConfig>
getAIVisionRuntimeConfig: () => Promise<AIVisionRuntimeConfig>
getAiSearchProviderStatus: () => Promise<AiSearchProviderStatus>
authorizeAiSearchExternalProvider: (
request: AiSearchExternalAuthorizationRequest
@@ -301,16 +316,33 @@ declare global {
httpStatus?: number
}>
startExport: (request: ExportRequest) => Promise<ExportResult>
selectExportDirectory: () => Promise<{ canceled: boolean; path?: string }>
cancelExport: (jobId: string) => Promise<{ success: boolean }>
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
exportGroupReportSnapshot: (
request: GroupReportRenderSnapshotExportRequest
) => Promise<GroupReportExportResult>
prepareGeneratedReportTemplateSwitch: (
reportId: string
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
listGeneratedReports: () => Promise<ReportHistoryResult>
saveGeneratedReport: (
request: SaveGeneratedReportRequest
) => Promise<SaveGeneratedReportResult>
updateGeneratedReportTemplate: (
request: UpdateGeneratedReportTemplateRequest
) => Promise<UpdateGeneratedReportTemplateResult>
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getWechatShareConfig: () => Promise<WechatShareServiceConfigResult>
saveWechatShareConfig: (
config: WechatShareServiceConfig
) => Promise<WechatShareServiceConfigResult>
publishWechatShareCard: (
request: PublishWechatShareCardRequest
) => Promise<PublishWechatShareCardResult>
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
readDatabaseKeyClipboard: () => Promise<{
+25 -2
View File
@@ -1,7 +1,13 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
import type {
GroupReportExportRequest,
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import type {
SaveGeneratedReportRequest,
UpdateGeneratedReportTemplateRequest
} from '../shared/report-history'
import type {
AIChatRequestOptions,
AiSearchExternalAuthorizationRequest,
@@ -48,6 +54,10 @@ import type {
KnowledgeSearchIpcRequest,
KnowledgeSearchIpcResult
} from '../shared/knowledge'
import type {
PublishWechatShareCardRequest,
WechatShareServiceConfig
} from '../shared/wechat-share-card'
// 渲染器的自定义 API
const api = {
@@ -114,6 +124,7 @@ const api = {
ipcRenderer.invoke('ai:chat', messages, options),
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
getAIRuntimeConfig: () => ipcRenderer.invoke('ai:getRuntimeConfig'),
getAIVisionRuntimeConfig: () => ipcRenderer.invoke('ai:getVisionRuntimeConfig'),
getAiSearchProviderStatus: (): Promise<AiSearchProviderStatus> =>
ipcRenderer.invoke('ai-search:getProviderStatus'),
authorizeAiSearchExternalProvider: (
@@ -198,6 +209,7 @@ const api = {
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
selectExportDirectory: () => ipcRenderer.invoke('export:selectDirectory'),
onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
callback(progress)
@@ -206,12 +218,23 @@ const api = {
},
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
exportGroupReportSnapshot: (request: GroupReportRenderSnapshotExportRequest) =>
ipcRenderer.invoke('report:exportSnapshot', request),
prepareGeneratedReportTemplateSwitch: (reportId: string) =>
ipcRenderer.invoke('report:prepareTemplateSwitch', { reportId }),
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
ipcRenderer.invoke('report:saveGenerated', request),
updateGeneratedReportTemplate: (request: UpdateGeneratedReportTemplateRequest) =>
ipcRenderer.invoke('report:updateGeneratedTemplate', request),
deleteGeneratedReport: (reportId: string) =>
ipcRenderer.invoke('report:deleteGenerated', reportId),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getWechatShareConfig: () => ipcRenderer.invoke('wechat-share:getConfig'),
saveWechatShareConfig: (config: WechatShareServiceConfig) =>
ipcRenderer.invoke('wechat-share:saveConfig', config),
publishWechatShareCard: (request: PublishWechatShareCardRequest) =>
ipcRenderer.invoke('wechat-share:publish', request),
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
+177 -13
View File
@@ -6,7 +6,11 @@ import { ApiWorkspace } from './features/api-center/ApiWorkspace'
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
import type { SettingsCategoryId } from './features/settings/model/types'
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
import type {
AIProviderSummary,
AIRuntimeModelConfig,
ReportModelChoice
} from '../../shared/ai-provider'
import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
@@ -31,6 +35,8 @@ import {
sortMessagesChronologically
} from './utils/message-pages'
import { enrichQuotedMessages } from './utils/quoted-messages'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -71,6 +77,51 @@ const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20
const REPORT_TEXT_MODEL_STORAGE_KEY = 'group_report_text_model'
const REPORT_VISION_MODEL_STORAGE_KEY = 'group_report_vision_model'
const reportModelKey = (model: { providerId?: string; model: string }): string =>
model.providerId && model.model ? `${model.providerId}::${model.model}` : ''
const reportProviderConfigured = (provider: AIProviderSummary): boolean =>
Boolean(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
const reportModelChoices = (
providers: AIProviderSummary[],
capability: 'chat' | 'vision'
): ReportModelChoice[] =>
providers.flatMap((provider) => {
if (!reportProviderConfigured(provider)) return []
return provider.models
.filter((model) =>
capability === 'chat'
? model.capabilities.chat
: model.capabilities.vision || model.capabilities.ocr
)
.map((model) => ({
providerId: provider.id,
providerName: provider.name,
model: model.id,
modelName: model.name || model.id,
configured: true as const,
status: provider.status,
timeoutMs: provider.advanced.timeoutMs
}))
})
const selectReportModel = (
choices: ReportModelChoice[],
storageKey: string,
fallback: { providerId?: string; model: string }
): ReportModelChoice | undefined => {
const savedKey = localStorage.getItem(storageKey) || ''
const fallbackKey = reportModelKey(fallback)
return (
choices.find((choice) => reportModelKey(choice) === savedKey) ||
choices.find((choice) => reportModelKey(choice) === fallbackKey) ||
choices[0]
)
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
@@ -207,6 +258,16 @@ function App(): React.ReactElement {
configured: false,
status: 'untested'
})
const [reportTextModelConfig, setReportTextModelConfig] = useState<AiModelConfig>({
providerName: '尚未配置',
model: '',
modelName: '尚未选择模型',
configured: false,
status: 'untested'
})
const [aiVisionModelConfig, setAiVisionModelConfig] = useState<ReportModelChoice>()
const [reportTextModelOptions, setReportTextModelOptions] = useState<ReportModelChoice[]>([])
const [reportVisionModelOptions, setReportVisionModelOptions] = useState<ReportModelChoice[]>([])
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
@@ -334,7 +395,25 @@ function App(): React.ReactElement {
localStorage.removeItem('ai_model')
}
}
setAiModelConfig(await window.api.getAIRuntimeConfig())
const [runtime, visionRuntime, providerList] = await Promise.all([
window.api.getAIRuntimeConfig(),
window.api.getAIVisionRuntimeConfig(),
window.api.listAIProviders()
])
const providers = providerList.success ? providerList.providers : []
const textChoices = reportModelChoices(providers, 'chat')
const visionChoices = reportModelChoices(providers, 'vision')
const selectedText = selectReportModel(textChoices, REPORT_TEXT_MODEL_STORAGE_KEY, runtime)
const selectedVision = selectReportModel(
visionChoices,
REPORT_VISION_MODEL_STORAGE_KEY,
visionRuntime
)
setReportTextModelOptions(textChoices)
setReportVisionModelOptions(visionChoices)
setAiModelConfig(runtime)
setReportTextModelConfig(selectedText || runtime)
setAiVisionModelConfig(selectedVision)
} catch (error) {
console.warn('[AI Provider] 配置加载失败:', error)
}
@@ -347,7 +426,8 @@ function App(): React.ReactElement {
sourceContact: reportSourceContact,
summaryDateRange,
summaryMessageTypes,
modelConfig: aiModelConfig
modelConfig: reportTextModelConfig,
visionModelConfig: aiVisionModelConfig
})
const lastCapturedReportKeyRef = React.useRef('')
@@ -1462,7 +1542,9 @@ function App(): React.ReactElement {
reportGeneration.phase !== 'success' ||
!reportSourceContact ||
!reportGeneration.generatedImage ||
!reportGeneration.reportPaths
!reportGeneration.reportPaths ||
!reportGeneration.reportSnapshot ||
!reportGeneration.reportMetadata
) {
return
}
@@ -1472,6 +1554,8 @@ function App(): React.ReactElement {
lastCapturedReportKeyRef.current = recordKey
setLatestGeneratedReportId(null)
setIsSavingGeneratedReport(true)
const reportSnapshot = reportGeneration.reportSnapshot
const reportMetadata = reportGeneration.reportMetadata
const saveReport = async (): Promise<void> => {
const result = await window.api.saveGeneratedReport({
@@ -1490,9 +1574,15 @@ function App(): React.ReactElement {
htmlPath: reportGeneration.reportPaths?.htmlPath,
pngPath: reportGeneration.reportPaths?.pngPath,
duration: reportGeneration.generationMetadata.durationMs,
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
textModelName:
reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
imageModelName: aiVisionModelConfig?.modelName || aiVisionModelConfig?.model,
modelName: reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
generationLogs: reportGeneration.generationMetadata.generationLogs
generationLogs: reportGeneration.generationMetadata.generationLogs,
reportSnapshot,
reportMetadata,
templateId: reportGeneration.templateId
})
if (!result.success || !result.record) {
@@ -1512,12 +1602,18 @@ function App(): React.ReactElement {
void saveReport()
}, [
aiModelConfig.model,
aiVisionModelConfig?.model,
aiVisionModelConfig?.modelName,
reportTextModelConfig.model,
reportTextModelConfig.modelName,
reportGeneration.generatedImage,
reportGeneration.generationMetadata,
reportGeneration.phase,
reportGeneration.reportMessages.length,
reportGeneration.reportMetadata,
reportGeneration.reportPaths,
reportGeneration.reportSnapshot,
reportGeneration.templateId,
reportSourceContact,
summaryDateRange
])
@@ -1564,6 +1660,30 @@ function App(): React.ReactElement {
return window.api.revealGroupReport(filePath)
}
const handleSwitchReportTemplate = async (
report: GeneratedReportRecord,
templateId: SelectableReportTemplateId
): Promise<{ success: boolean; error?: string }> => {
try {
const updated = await withTimeout(
switchGeneratedReportTemplate(report, templateId, window.api),
120_000,
'模板切换超时,请稍后重试'
)
if (!updated.success || !updated.record) {
return { success: false, error: updated.error || '日报模板更新失败' }
}
setGeneratedReports((current) =>
current.map((item) => (item.id === report.id ? updated.record! : item))
)
reportGeneration.setTemplateId(templateId)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
const handleDeleteReport = async (
reportId: string
): Promise<{ success: boolean; error?: string }> => {
@@ -1634,6 +1754,7 @@ function App(): React.ReactElement {
onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport}
onSwitchTemplate={handleSwitchReportTemplate}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div>
@@ -1652,7 +1773,10 @@ function App(): React.ReactElement {
sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes}
modelConfig={aiModelConfig}
modelConfig={reportTextModelConfig}
visionModelConfig={aiVisionModelConfig}
textModelOptions={reportTextModelOptions}
visionModelOptions={reportVisionModelOptions}
rangeMessageCount={reportGeneration.rangeMessages.length}
reportMessageCount={reportGeneration.reportMessages.length}
messageTypeCounts={reportGeneration.messageTypeCounts}
@@ -1665,6 +1789,14 @@ function App(): React.ReactElement {
onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={openModelSettings}
onTextModelChange={(model) => {
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
setReportTextModelConfig(model)
}}
onVisionModelChange={(model) => {
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
setAiVisionModelConfig(model)
}}
onGenerate={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.generate()
@@ -1686,10 +1818,13 @@ function App(): React.ReactElement {
error={reportGeneration.error}
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
onRetry={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.retry()
}}
preparationProgress={reportGeneration.preparationProgress}
imageInsightSummary={reportGeneration.imageInsightSummary}
canRetryModelStep={reportGeneration.canRetryModelStep}
currentModel={reportTextModelConfig}
onRetry={(model) => void reportGeneration.retry(model)}
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
/>
</div>
)
@@ -1725,7 +1860,36 @@ function App(): React.ReactElement {
onContactsChange={setContacts}
onFilteredContactsChange={setFilteredContacts}
onReturnToLogin={handleReturnToLogin}
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
onAIRuntimeChange={(config: AIRuntimeModelConfig) => {
setAiModelConfig(config)
void Promise.all([
window.api.getAIVisionRuntimeConfig(),
window.api.listAIProviders()
])
.then(([visionRuntime, providerList]) => {
const providers = providerList.success ? providerList.providers : []
const textChoices = reportModelChoices(providers, 'chat')
const visionChoices = reportModelChoices(providers, 'vision')
setReportTextModelOptions(textChoices)
setReportVisionModelOptions(visionChoices)
setReportTextModelConfig(
(current) =>
selectReportModel(
textChoices,
REPORT_TEXT_MODEL_STORAGE_KEY,
current.configured ? current : config
) || config
)
setAiVisionModelConfig((current) =>
selectReportModel(
visionChoices,
REPORT_VISION_MODEL_STORAGE_KEY,
current || visionRuntime
)
)
})
.catch(() => undefined)
}}
onNotice={setReportNotice}
onOpenSettings={openSettings}
onAppearanceChange={handleAppearanceChange}
@@ -249,7 +249,7 @@ export function DatabaseConnectionPage({
'让微信停在登录页面',
'确认开始准备',
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
'现在可以登录微信',
'获取成功, 现在可以重新登录微信',
'验证数据库连接'
][guideStep - 1]}
</strong>
@@ -426,7 +426,7 @@ export function DatabaseConnectionPage({
onClick={onValidateConnection}
disabled={!dbKey || isConnecting}
>
{isConnecting ? '正在验证…' : '微信已登录,验证连接'}
{isConnecting ? '正在验证…' : '验证连接'}
</button>
)}
{guideStep === 6 && (
@@ -75,6 +75,7 @@ export function ExportWorkspace({
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
const [fileName, setFileName] = useState('')
const [outputDirectory, setOutputDirectory] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
@@ -334,6 +335,7 @@ export function ExportWorkspace({
targets,
format: exportFormat,
outputName,
outputDirectory: outputDirectory || undefined,
startTime: exportAll
? undefined
: startOfRange
@@ -439,6 +441,10 @@ export function ExportWorkspace({
: `文稿/TraceMemo/导出/${outputName}/`
: `文稿/TraceMemo/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
const selectedTargetPath = outputDirectory
? `${outputDirectory}/${outputName}${format === 'html' ? (zip ? '.zip' : '/') : `.${format === 'markdown' ? 'md' : format}`}`
: targetPath
return (
<div className="export-workspace">
<ExportContactPanel
@@ -757,8 +763,12 @@ export function ExportWorkspace({
</label>
<div className="export-target-path">
<span></span>
<strong>{targetPath}</strong>
<button type="button"></button>
<strong>{selectedTargetPath}</strong>
<button type="button" onClick={() => {
void window.api.selectExportDirectory().then((result) => {
if (!result.canceled && result.path) setOutputDirectory(result.path)
})
}}></button>
</div>
{format === 'html' && (
<p className="export-helper-text">
@@ -776,7 +786,7 @@ export function ExportWorkspace({
? '导出完成'
: '准备就绪'}
</span>
<span className="export-target-summary">{targetPath}</span>
<span className="export-target-summary">{selectedTargetPath}</span>
<button type="button" className="export-reset-button" onClick={resetDefaults}>
</button>
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react'
import { Contact } from '../../../../shared/types'
import type { ReportModelChoice } from '../../../../shared/ai-provider'
import {
AiModelConfig,
RangeMessageState,
@@ -15,13 +16,16 @@ import { ReportRangeSelector } from './ReportRangeSelector'
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
import { ReportSectionSelector } from './ReportSectionSelector'
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
import { ReportTemplateSelector, SelectableReportTemplateId } from './ReportTemplateSelector'
interface AiReportWorkspaceProps {
sourceContact: Contact | null
summaryDateRange: SummaryDateRange
summaryMessageTypes: SummaryMessageType[]
modelConfig: AiModelConfig
visionModelConfig?: ReportModelChoice
textModelOptions: ReportModelChoice[]
visionModelOptions: ReportModelChoice[]
rangeMessageCount: number
reportMessageCount: number
messageTypeCounts: Record<SummaryMessageType, number>
@@ -34,14 +38,16 @@ interface AiReportWorkspaceProps {
onSummaryDateRangeChange: (value: SummaryDateRange) => void
onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void
onOpenModelSettings: () => void
onTextModelChange: (model: ReportModelChoice) => void
onVisionModelChange: (model: ReportModelChoice) => void
onGenerate: () => void
onCloseResult: () => void
onCopyImage: () => Promise<{ success: boolean; error?: string }>
onRevealReport: () => Promise<{ success: boolean; error?: string }>
onViewResult: () => void
hasReportResult: boolean
templateId: ReportTemplateId
onTemplateIdChange: (value: ReportTemplateId) => void
templateId: SelectableReportTemplateId
onTemplateIdChange: (value: SelectableReportTemplateId) => void
memberNamePreference: ReportMemberNamePreference
onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void
reportTimeoutSeconds: number
@@ -69,6 +75,9 @@ export function AiReportWorkspace({
summaryDateRange,
summaryMessageTypes,
modelConfig,
visionModelConfig,
textModelOptions,
visionModelOptions,
rangeMessageCount,
reportMessageCount,
messageTypeCounts,
@@ -81,6 +90,8 @@ export function AiReportWorkspace({
onSummaryDateRangeChange,
onSummaryMessageTypesChange,
onOpenModelSettings,
onTextModelChange,
onVisionModelChange,
onGenerate,
onCloseResult,
onCopyImage,
@@ -171,7 +182,16 @@ export function AiReportWorkspace({
disabled={configDisabled}
/>
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
<ModelSummary
config={modelConfig}
visionConfig={visionModelConfig}
textModels={textModelOptions}
visionModels={visionModelOptions}
disabled={configDisabled}
onTextModelChange={onTextModelChange}
onVisionModelChange={onVisionModelChange}
onOpenSettings={onOpenModelSettings}
/>
<section className="report-config-section report-timeout-section">
<div>
<h3></h3>
@@ -1,24 +1,87 @@
import React from 'react'
import type { ReportModelChoice } from '../../../../shared/ai-provider'
import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
interface ModelSummaryProps {
config: AiModelConfig
visionConfig?: ReportModelChoice
textModels: ReportModelChoice[]
visionModels: ReportModelChoice[]
disabled?: boolean
onTextModelChange: (model: ReportModelChoice) => void
onVisionModelChange: (model: ReportModelChoice) => void
onOpenSettings: () => void
}
export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement {
const statusText = config.configured ? '配置正常' : '尚未配置'
const modelKey = (model: { providerId?: string; model: string } | undefined): string =>
model?.providerId && model.model ? `${model.providerId}::${model.model}` : ''
const optionLabel = (model: ReportModelChoice): string =>
`${model.providerName} · ${model.modelName || model.model}`
export function ModelSummary({
config,
visionConfig,
textModels,
visionModels,
disabled = false,
onTextModelChange,
onVisionModelChange,
onOpenSettings
}: ModelSummaryProps): React.ReactElement {
const changeModel = (
key: string,
models: ReportModelChoice[],
onChange: (model: ReportModelChoice) => void
): void => {
const selected = models.find((model) => modelKey(model) === key)
if (selected) onChange(selected)
}
return (
<section className="report-config-section">
<div className="report-model-summary">
<div>
<div className="report-model-summary-content">
<h3></h3>
<p>
{config.modelName || config.model || '未选择模型'} · {statusText}
</p>
<div className="report-model-selects">
<label>
<span></span>
<select
aria-label="文字总结模型"
value={modelKey(config)}
disabled={disabled || !textModels.length}
onChange={(event) => changeModel(event.target.value, textModels, onTextModelChange)}
>
{!textModels.length && <option value=""></option>}
{textModels.map((model) => (
<option key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</option>
))}
</select>
</label>
<label>
<span></span>
<select
aria-label="图片理解模型"
value={modelKey(visionConfig)}
disabled={disabled || !visionModels.length}
onChange={(event) =>
changeModel(event.target.value, visionModels, onVisionModelChange)
}
>
{!visionModels.length && <option value=""></option>}
{visionModels.map((model) => (
<option key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</option>
))}
</select>
</label>
</div>
<small> 10 </small>
</div>
<button type="button" onClick={onOpenSettings}>
<button type="button" onClick={onOpenSettings} disabled={disabled}>
</button>
</div>
@@ -108,8 +108,12 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea
<b>{formatDuration(report.duration)}</b>
</div>
<div>
<span></span>
<b>{modelLabel(report.modelName)}</b>
<span></span>
<b>{modelLabel(report.textModelName || report.modelName)}</b>
</div>
<div>
<span></span>
<b>{modelLabel(report.imageModelName)}</b>
</div>
<div>
<span>Token </span>
@@ -1,31 +1,89 @@
import React, { useEffect, useState } from 'react'
import type { AIProviderSummary } from '../../../../shared/ai-provider'
import {
AiModelConfig,
REPORT_TASK_STEPS,
ReportGenerationPhase,
VoiceTranscriptionProgress
} from '../../hooks/useGroupReportGeneration'
import type {
ReportImageInsightSummary,
ReportPreparationProgress
} from '../../utils/group-report-facts'
interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase
error: string
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
voiceTranscriptionEnabled: boolean
onRetry: () => void
preparationProgress: ReportPreparationProgress | null
imageInsightSummary: ReportImageInsightSummary
canRetryModelStep: boolean
currentModel: AiModelConfig
onRetry: (model?: AiModelConfig) => void
onContinueAfterImageFailures: () => void
onCancelAfterImageFailures: () => void
}
interface ModelChoice {
key: string
label: string
config: AiModelConfig
}
const providerCanRun = (provider: AIProviderSummary): boolean =>
Boolean(
provider.models.some((model) => model.capabilities.chat) &&
(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
)
const modelChoices = (providers: AIProviderSummary[]): ModelChoice[] =>
providers.flatMap((provider) =>
providerCanRun(provider)
? provider.models
.filter((model) => model.capabilities.chat)
.map((model) => ({
key: `${provider.id}::${model.id}`,
label: `${provider.name} · ${model.name || model.id}`,
config: {
providerId: provider.id,
providerName: provider.name,
model: model.id,
modelName: model.name || model.id,
configured: true,
status: provider.status,
timeoutMs: provider.advanced.timeoutMs
}
}))
: []
)
export function ReportTaskStatusPanel({
phase,
error,
voiceTranscriptionProgress,
voiceTranscriptionEnabled,
onRetry
preparationProgress,
imageInsightSummary,
canRetryModelStep,
currentModel,
onRetry,
onContinueAfterImageFailures,
onCancelAfterImageFailures
}: ReportTaskStatusPanelProps): React.ReactElement {
const taskSteps = voiceTranscriptionEnabled
? REPORT_TASK_STEPS
: REPORT_TASK_STEPS.filter((step) => step.id !== 'transcribingVoice')
const activeIndex = taskSteps.findIndex((step) => step.id === phase)
const effectiveActiveIndex =
phase === 'awaitingImageDecision'
? taskSteps.findIndex((step) => step.id === 'preparingInput')
: activeIndex
const completedAll = phase === 'success'
const [logPath, setLogPath] = useState('')
const [choices, setChoices] = useState<ModelChoice[]>([])
const [selectedModelKey, setSelectedModelKey] = useState('')
const [modelLoadError, setModelLoadError] = useState('')
useEffect(() => {
void window.api
@@ -34,6 +92,39 @@ export function ReportTaskStatusPanel({
.catch(() => undefined)
}, [])
useEffect(() => {
if (!canRetryModelStep) return
let active = true
void window.api
.listAIProviders()
.then((result) => {
if (!active) return
if (!result.success) {
setModelLoadError(result.error || '模型列表读取失败')
return
}
const nextChoices = modelChoices(result.providers)
setChoices(nextChoices)
const currentKey = `${currentModel.providerId || ''}::${currentModel.model}`
setSelectedModelKey(
nextChoices.some((choice) => choice.key === currentKey)
? currentKey
: nextChoices[0]?.key || ''
)
})
.catch((loadError) => {
if (active) {
setModelLoadError(loadError instanceof Error ? loadError.message : '模型列表读取失败')
}
})
return () => {
active = false
}
}, [canRetryModelStep, currentModel.model, currentModel.providerId])
const selectedModel = choices.find((choice) => choice.key === selectedModelKey)?.config
const imageDecisionPending = phase === 'awaitingImageDecision'
return (
<aside className="report-task-panel">
<div className="report-task-header">
@@ -41,19 +132,21 @@ export function ReportTaskStatusPanel({
<p>
{completedAll
? '生成完成'
: phase === 'error'
? '生成失败'
: activeIndex >= 0
? `${activeIndex + 1}/${taskSteps.length}`
: '等待开始'}
: imageDecisionPending
? '等待确认'
: phase === 'error'
? '生成失败'
: effectiveActiveIndex >= 0
? `${effectiveActiveIndex + 1}/${taskSteps.length}`
: '等待开始'}
</p>
</div>
<div className="report-task-steps">
{taskSteps.map((step, index) => {
const state =
completedAll || (activeIndex >= 0 && index < activeIndex)
completedAll || (effectiveActiveIndex >= 0 && index < effectiveActiveIndex)
? 'done'
: activeIndex === index
: effectiveActiveIndex === index
? 'active'
: 'waiting'
return (
@@ -69,6 +162,76 @@ export function ReportTaskStatusPanel({
)
})}
</div>
{(phase === 'preparingInput' || phase === 'requestingModel' || imageDecisionPending) &&
preparationProgress && (
<div className="report-preparation-progress">
<div>
<strong>{preparationProgress.label}</strong>
{preparationProgress.total ? (
<span>
{preparationProgress.completed || 0}/{preparationProgress.total}
</span>
) : null}
</div>
{preparationProgress.total ? (
<progress
value={preparationProgress.completed || 0}
max={preparationProgress.total}
aria-label="图片识别进度"
/>
) : null}
</div>
)}
{imageInsightSummary.total > 0 && (
<details className="report-image-insights" open={imageDecisionPending}>
<summary>
{imageInsightSummary.succeeded}
{imageInsightSummary.failed > 0 ? ` · 失败 ${imageInsightSummary.failed}` : ''}
</summary>
<div className="report-image-insight-list">
{imageInsightSummary.items.map((item) => (
<article key={`${item.messageId}:${item.time}`}>
<div>
<b>{item.sender}</b>
<time>{item.time}</time>
</div>
<p>{item.description}</p>
{item.ocrText ? <small>OCR{item.ocrText}</small> : null}
{item.tags.length ? <small>{item.tags.join(' / ')}</small> : null}
</article>
))}
{imageInsightSummary.failures.map((failure, index) => (
<article
key={`${failure.messageId || failure.sender}:${failure.time || index}`}
className="failed"
>
<div>
<b>{failure.sender}</b>
{failure.time ? <time>{failure.time}</time> : null}
</div>
<p>{failure.error}</p>
</article>
))}
</div>
</details>
)}
{imageDecisionPending && (
<div className="report-image-decision">
<b> {imageInsightSummary.failed} </b>
<p>
{imageInsightSummary.succeeded}{' '}
</p>
<div>
<button type="button" onClick={onContinueAfterImageFailures}>
</button>
<button type="button" onClick={onCancelAfterImageFailures}>
</button>
</div>
</div>
)}
{phase === 'transcribingVoice' && voiceTranscriptionProgress && (
<div className="report-voice-progress">
<div>
@@ -92,9 +255,35 @@ export function ReportTaskStatusPanel({
<div className="report-task-error">
<b></b>
<p>{error}</p>
<button type="button" onClick={onRetry}>
</button>
{canRetryModelStep ? (
<div className="report-model-retry">
<label htmlFor="report-retry-model"></label>
<select
id="report-retry-model"
value={selectedModelKey}
onChange={(event) => setSelectedModelKey(event.target.value)}
>
{choices.map((choice) => (
<option key={choice.key} value={choice.key}>
{choice.label}
</option>
))}
</select>
<small></small>
{modelLoadError ? <small className="error">{modelLoadError}</small> : null}
<button
type="button"
disabled={!selectedModel}
onClick={() => onRetry(selectedModel)}
>
使
</button>
</div>
) : (
<button type="button" onClick={() => onRetry()}>
</button>
)}
<button type="button" onClick={() => void window.api.revealAppLog()}>
</button>
@@ -1,136 +1,159 @@
import React, { useState } from 'react'
import {
DEFAULT_REPORT_TEMPLATE,
REPORT_TEMPLATES,
type ReportTemplateDefinition,
type SelectableReportTemplateId
} from '../../../../shared/report-templates'
export type ReportTemplateId = 'v1' | 'v2'
interface TemplateMeta {
id: ReportTemplateId
label: string
tagline: string
preview: { title: string; sections: string[] }
}
const TEMPLATES: TemplateMeta[] = [
{
id: 'v1',
label: '模板1 · 经典日报',
tagline: '实用信息 / 重要消息 / 金句 / 问答 / 数据可视化',
preview: {
title: '经典日报',
sections: [
'今日讨论热点',
'AI 识别的图片精选',
'实用信息与资源',
'重要消息汇总',
'有趣对话或金句',
'问题与解答',
'群内数据可视化',
'词云/关键词'
]
}
},
{
id: 'v2',
label: '模板2 · 丰富日报',
tagline: '包含更多群聊分析板块;勾选图片后会尝试生成图片精选',
preview: {
title: '支持图片板块',
sections: [
'今日讨论热点',
'重要消息',
'待办事项和未解决问题',
'今日名场面',
'今日群数据',
'关键词',
'实用信息与资源',
'问题与解答',
'今日剧情时间线',
'群聊反转现场',
'AI 识别的图片精选',
'今日群相册',
'语音之最',
'语音时长榜',
'今日临时人设',
'话题参与链路'
]
}
}
]
export type { SelectableReportTemplateId } from '../../../../shared/report-templates'
interface ReportTemplateSelectorProps {
value: ReportTemplateId
onChange: (value: ReportTemplateId) => void
value: SelectableReportTemplateId
onChange: (value: SelectableReportTemplateId) => void
disabled?: boolean
}
const TemplateDiagram = ({
template
}: {
template: ReportTemplateDefinition
}): React.ReactElement => (
<div className={`report-template-diagram diagram-${template.id}`} aria-hidden="true">
<div className="diagram-masthead">
<i />
<b />
<span />
</div>
<div className="diagram-kpis">
<i />
<i />
<i />
<i />
</div>
<div className="diagram-content">
<div className="diagram-column diagram-column-a">
<b />
<span />
<span />
</div>
<div className="diagram-column diagram-column-b">
<b />
<span />
<span />
<span />
</div>
<div className="diagram-column diagram-column-c">
<b />
<span />
<span />
</div>
</div>
</div>
)
export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
value,
onChange,
disabled
}) => {
const [previewing, setPreviewing] = useState<TemplateMeta | null>(null)
return (
<section className="report-section">
<h3></h3>
<p className="report-section-desc">
</p>
const [previewing, setPreviewing] = useState<ReportTemplateDefinition | null>(null)
const mobileTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')
const desktopTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')
const renderGroup = (
title: string,
templates: readonly ReportTemplateDefinition[]
): React.ReactElement => (
<div className="report-template-group">
<div className="report-template-group-title">{title}</div>
<div className="report-template-list">
{TEMPLATES.map((tpl) => {
const active = value === tpl.id
{templates.map((template) => {
const active = value === template.id
return (
<div
key={tpl.id}
key={template.id}
className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
>
<label>
<input
type="radio"
name="report-template"
value={tpl.id}
value={template.id}
checked={active}
disabled={disabled}
onChange={() => onChange(tpl.id)}
onChange={() => onChange(template.id)}
/>
<TemplateDiagram template={template} />
<div className="report-template-body">
<div className="report-template-title">{tpl.label}</div>
<div className="report-template-tagline">{tpl.tagline}</div>
<div className="report-template-eyebrow">{template.label}</div>
<div className="report-template-title">{template.name}</div>
<div className="report-template-tagline">{template.tagline}</div>
</div>
</label>
<button
type="button"
className="report-template-preview-btn"
onClick={() => setPreviewing(tpl)}
onClick={() => setPreviewing(template)}
>
</button>
</div>
)
})}
</div>
</div>
)
return (
<section className="report-section">
<h3></h3>
<p className="report-section-desc">
</p>
<div className="report-template-catalog">
{renderGroup('默认模板', [DEFAULT_REPORT_TEMPLATE])}
{renderGroup('手机端 · 375414 px', mobileTemplates)}
{renderGroup('电脑端 · 12801920 px', desktopTemplates)}
</div>
{previewing && (
<div className="report-template-preview-mask" onClick={() => setPreviewing(null)}>
<div className="report-template-preview-card" onClick={(e) => e.stopPropagation()}>
<h4>{previewing.preview.title}</h4>
<p className="muted">{previewing.tagline}</p>
<div className="report-template-preview-frame">
<div className="fake-card fake-hero">
<div className="fake-title"> · </div>
<div className="fake-sub">2026-xx-xx · </div>
<div
className="report-template-preview-card"
onClick={(event) => event.stopPropagation()}
>
<div className="report-template-preview-heading">
<div>
<span>{previewing.label}</span>
<h4>{previewing.name}</h4>
</div>
{previewing.preview.sections.map((s) => (
<div className="fake-card fake-section" key={s}>
<div className="fake-bar" />
<div className="fake-section-title">{s}</div>
</div>
))}
<em>
{previewing.platform === 'desktop'
? '桌面宽屏'
: previewing.platform === 'default'
? '经典长图'
: '手机长图'}
</em>
</div>
<p className="muted">{previewing.tagline}</p>
<TemplateDiagram template={previewing} />
<p className="report-template-preview-note">
Q&amp;A
</p>
<div className="report-template-preview-actions">
<button type="button" className="secondary" onClick={() => setPreviewing(null)}>
</button>
<button
type="button"
onClick={() => {
onChange(previewing.id)
setPreviewing(null)
}}
>
</button>
</div>
<button
type="button"
className="report-template-preview-close"
onClick={() => setPreviewing(null)}
>
</button>
</div>
</div>
)}
@@ -1,34 +1,88 @@
import React, { useEffect, useRef, useState } from 'react'
import {
SELECTABLE_REPORT_TEMPLATES,
type SelectableReportTemplateId
} from '../../../../shared/report-templates'
interface ReportToolbarProps {
canCopyImage: boolean
canReveal: boolean
canShare: boolean
canSwitchTemplate: boolean
currentTemplateId?: SelectableReportTemplateId
isSwitchingTemplate: boolean
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
onRegenerate: () => void
onCopyImage: () => void
onReveal: () => void
onShare: () => void
}
export function ReportToolbar({
canCopyImage,
canReveal,
canShare,
canSwitchTemplate,
currentTemplateId,
isSwitchingTemplate,
onSwitchTemplate,
onRegenerate,
onCopyImage,
onReveal
onReveal,
onShare
}: ReportToolbarProps): React.ReactElement {
const [moreOpen, setMoreOpen] = useState(false)
const [templateOpen, setTemplateOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const templateMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!moreOpen) return
if (!moreOpen && !templateOpen) return
const close = (event: PointerEvent): void => {
if (!menuRef.current?.contains(event.target as Node)) setMoreOpen(false)
if (!templateMenuRef.current?.contains(event.target as Node)) setTemplateOpen(false)
}
window.addEventListener('pointerdown', close)
return () => window.removeEventListener('pointerdown', close)
}, [moreOpen])
}, [moreOpen, templateOpen])
return (
<div className="report-viewer-toolbar">
<div className="report-template-switch-menu" ref={templateMenuRef}>
<button
type="button"
disabled={!canSwitchTemplate || isSwitchingTemplate}
title={
canSwitchTemplate
? '使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
: '当前报告缺少可复用数据和 HTML,无法切换模板'
}
onClick={() => setTemplateOpen((open) => !open)}
>
{isSwitchingTemplate ? '切换中…' : '切换模板'}
</button>
{templateOpen && canSwitchTemplate && (
<div className="report-template-switch-popover" role="menu" aria-label="切换日报模板">
<p> AI</p>
{SELECTABLE_REPORT_TEMPLATES.map((template) => (
<button
type="button"
role="menuitem"
className={template.id === currentTemplateId ? 'active' : undefined}
key={template.id}
onClick={() => {
setTemplateOpen(false)
onSwitchTemplate(template.id)
}}
>
<span>{template.label}</span>
<b>{template.name}</b>
{template.id === currentTemplateId && <i></i>}
</button>
))}
</div>
)}
</div>
<button type="button" onClick={onRegenerate}>
</button>
@@ -38,6 +92,9 @@ export function ReportToolbar({
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}>
</button>
<button type="button" disabled={!canShare} onClick={onShare}>
</button>
<div className="report-more-menu" ref={menuRef}>
<button type="button" onClick={() => setMoreOpen((open) => !open)}>
@@ -3,6 +3,8 @@ import type { GeneratedReportRecord } from './types'
import { ReportEmptyState } from './ReportEmptyState'
import { ReportToolbar } from './ReportToolbar'
import { ReportZoomBar } from './ReportZoomBar'
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
import { WechatShareCardDialog } from './WechatShareCardDialog'
interface ReportViewerProps {
report: GeneratedReportRecord | null
@@ -11,19 +13,44 @@ interface ReportViewerProps {
onRegenerate: () => void
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onSwitchTemplate: (
report: GeneratedReportRecord,
templateId: SelectableReportTemplateId
) => Promise<{ success: boolean; error?: string }>
}
const calculateFitZoom = (
viewportWidth: number,
viewportHeight: number,
imageWidth: number,
imageHeight: number
): number =>
Math.min(
1,
Math.min(
Math.max(1, viewportWidth - 44) / imageWidth,
Math.max(1, viewportHeight - 44) / imageHeight
)
)
const normalizeFitZoom = (value: number): number =>
Math.max(0.0001, Math.floor(value * 10_000) / 10_000)
export function ReportViewer({
report,
hasReports,
onBackToConfigure,
onRegenerate,
onCopyImage,
onReveal
onReveal,
onSwitchTemplate
}: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1)
const [fitZoom, setFitZoom] = useState(1)
const [status, setStatus] = useState('')
const [imageError, setImageError] = useState('')
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
const [shareDialogOpen, setShareDialogOpen] = useState(false)
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
const viewportRef = useRef<HTMLDivElement>(null)
@@ -31,7 +58,10 @@ export function ReportViewer({
const frame = window.requestAnimationFrame(() => {
setStatus('')
setImageError('')
setIsSwitchingTemplate(false)
setShareDialogOpen(false)
setZoom(1)
setFitZoom(1)
setNaturalSize(null)
})
return () => window.cancelAnimationFrame(frame)
@@ -39,13 +69,47 @@ export function ReportViewer({
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
const fitWidth = (): void => {
const measureFitZoom = (): number | null => {
const viewport = viewportRef.current
if (!viewport || !naturalSize?.width) return
const nextZoom = Math.min(2, Math.max(0.25, (viewport.clientWidth - 48) / naturalSize.width))
setZoom(Number(nextZoom.toFixed(2)))
if (!viewport || !naturalSize?.width || !naturalSize.height) return null
return calculateFitZoom(
viewport.clientWidth,
viewport.clientHeight,
naturalSize.width,
naturalSize.height
)
}
const fitPage = (): void => {
const nextFitZoom = measureFitZoom()
if (!nextFitZoom) return
setFitZoom(normalizeFitZoom(nextFitZoom))
setZoom(1)
}
const showActualSize = (): void => {
if (!fitZoom) return
setZoom(Math.min(64, Math.max(0.25, Number((1 / fitZoom).toFixed(4)))))
}
useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !naturalSize?.width || !naturalSize.height) return
const updateFitZoom = (): void => {
const nextFitZoom = calculateFitZoom(
viewport.clientWidth,
viewport.clientHeight,
naturalSize.width,
naturalSize.height
)
setFitZoom(normalizeFitZoom(nextFitZoom))
}
updateFitZoom()
const observer = new ResizeObserver(updateFitZoom)
observer.observe(viewport)
return () => observer.disconnect()
}, [naturalSize?.height, naturalSize?.width])
const handleCopy = async (): Promise<void> => {
if (!report) return
const result = await onCopyImage(report)
@@ -58,6 +122,22 @@ export function ReportViewer({
setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
}
const handleSwitchTemplate = async (templateId: SelectableReportTemplateId): Promise<void> => {
if (!report || isSwitchingTemplate) return
if (report.templateId === templateId) {
setStatus('当前已是所选模板')
return
}
setIsSwitchingTemplate(true)
setStatus('正在使用已有日报数据切换模板…')
try {
const result = await onSwitchTemplate(report, templateId)
setStatus(result.success ? '模板已切换,无需重新生成内容' : result.error || '模板切换失败')
} finally {
setIsSwitchingTemplate(false)
}
}
if (!report) {
return (
<main className="report-viewer">
@@ -88,9 +168,19 @@ export function ReportViewer({
<ReportToolbar
canCopyImage={Boolean(report.generatedImage)}
canReveal={Boolean(report.pngPath || report.htmlPath)}
canShare={Boolean(report.pngPath)}
canSwitchTemplate={Boolean(
(report.reportSnapshot && report.reportMetadata) ||
report.reportRenderSnapshot ||
(report.htmlStatus === 'ready' && report.htmlPath)
)}
currentTemplateId={report.templateId}
isSwitchingTemplate={isSwitchingTemplate}
onSwitchTemplate={(templateId) => void handleSwitchTemplate(templateId)}
onRegenerate={onRegenerate}
onCopyImage={() => void handleCopy()}
onReveal={() => void handleReveal()}
onShare={() => setShareDialogOpen(true)}
/>
</header>
{status && <div className="report-viewer-status">{status}</div>}
@@ -101,7 +191,9 @@ export function ReportViewer({
src={report.generatedImage}
alt={title}
style={{
width: naturalSize ? `${Math.round(naturalSize.width * zoom)}px` : undefined
width: naturalSize
? `${Math.max(1, Math.round(naturalSize.width * fitZoom * zoom))}px`
: undefined
}}
onLoad={(event) => {
const image = event.currentTarget
@@ -111,9 +203,15 @@ export function ReportViewer({
}
setNaturalSize(nextSize)
const viewport = viewportRef.current
if (viewport && nextSize.width > viewport.clientWidth - 48) {
const fittedZoom = Math.max(0.25, (viewport.clientWidth - 48) / nextSize.width)
setZoom(Number(fittedZoom.toFixed(2)))
if (viewport) {
const fittedZoom = calculateFitZoom(
viewport.clientWidth,
viewport.clientHeight,
nextSize.width,
nextSize.height
)
setFitZoom(normalizeFitZoom(fittedZoom))
setZoom(1)
}
}}
onError={() => {
@@ -134,7 +232,20 @@ export function ReportViewer({
/>
)}
</div>
<ReportZoomBar zoom={zoom} onZoomChange={setZoom} onFitWidth={fitWidth} />
<ReportZoomBar
zoom={zoom}
onZoomChange={setZoom}
onFitPage={fitPage}
onActualSize={showActualSize}
/>
{shareDialogOpen && report.pngPath && (
<WechatShareCardDialog
pngPath={report.pngPath}
initialTitle={`${report.contactName}日报 · ${report.reportDate}`}
initialDescription={`基于 ${report.messageCount} 条群聊消息生成的 AI 日报`}
onClose={() => setShareDialogOpen(false)}
/>
)}
</main>
)
}
@@ -3,27 +3,32 @@ import React from 'react'
interface ReportZoomBarProps {
zoom: number
onZoomChange: (zoom: number) => void
onFitWidth: () => void
onFitPage: () => void
onActualSize: () => void
}
const clampZoom = (value: number): number => Math.min(2, Math.max(0.25, value))
const clampZoom = (value: number): number => Math.min(64, Math.max(0.25, value))
export function ReportZoomBar({
zoom,
onZoomChange,
onFitWidth
onFitPage,
onActualSize
}: ReportZoomBarProps): React.ReactElement {
return (
<div className="report-zoom-bar">
<button type="button" onClick={() => onZoomChange(clampZoom(zoom - 0.1))}>
<button type="button" onClick={() => onZoomChange(clampZoom(zoom / 1.25))}>
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => onZoomChange(clampZoom(zoom + 0.1))}>
<span title="100% 为完整显示在当前预览框内">{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => onZoomChange(clampZoom(zoom * 1.25))}>
</button>
<button type="button" onClick={onFitWidth}>
<button type="button" onClick={onFitPage}>
</button>
<button type="button" onClick={onActualSize}>
</button>
</div>
)
@@ -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
)
}
+3 -1
View File
@@ -4,7 +4,9 @@ export type {
ReportAssetStatus,
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
SaveGeneratedReportResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../../../../shared/report-history'
export type ReportWorkspaceView = 'configure' | 'result'
@@ -43,7 +43,7 @@ export function AIProviderEditor({
)
return (
<section className="settings-card ai-provider-editor">
<section id="ai-provider-editor" className="settings-card ai-provider-editor">
<header>
<div>
<h2>{editing ? '编辑供应商' : '新增供应商'}</h2>
@@ -66,7 +66,11 @@ export function AIProviderEditor({
<div className="ai-provider-form-grid">
<label>
<input value={provider.name} onChange={(event) => patch({ name: event.target.value })} />
<input
id="ai-provider-name"
value={provider.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
ID
@@ -148,16 +152,22 @@ export function AIProviderEditor({
<div className="ai-model-table">
{provider.models.map((model, index) => (
<div className="ai-model-row" key={`${index}-${model.id}`}>
<input
value={model.name}
onChange={(event) => patchModel(index, { name: event.target.value })}
placeholder="显示名称"
/>
<input
value={model.id}
onChange={(event) => patchModel(index, { id: event.target.value })}
placeholder="实际模型 ID"
/>
<label className="ai-model-identity-field">
<span></span>
<input
value={model.name}
onChange={(event) => patchModel(index, { name: event.target.value })}
placeholder="例如:DeepSeek Chat"
/>
</label>
<label className="ai-model-identity-field">
<span> ID</span>
<input
value={model.id}
onChange={(event) => patchModel(index, { id: event.target.value })}
placeholder="例如:deepseek-chat"
/>
</label>
<label>
<input
type="checkbox"
@@ -314,5 +324,9 @@ export function AIProviderEditor({
}
function emptyModel(): AIModelDefinition {
return { name: '', id: '', capabilities: { chat: true, vision: false, ocr: false, longContext: false } }
return {
name: '',
id: '',
capabilities: { chat: true, vision: false, ocr: false, longContext: false }
}
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useReducer } from 'react'
import { useCallback, useEffect, useReducer, useRef } from 'react'
import type {
AIProviderConfig,
AIProviderSummary,
@@ -16,6 +16,11 @@ export function useAIModelSettingsController({
onNotice: (message: string) => void
}): AIModelSettingsController {
const [state, dispatch] = useReducer(aiModelSettingsReducer, initialAIModelSettingsState)
const onRuntimeChangeRef = useRef(onRuntimeChange)
useEffect(() => {
onRuntimeChangeRef.current = onRuntimeChange
}, [onRuntimeChange])
const refresh = useCallback(async (): Promise<void> => {
const [list, runtime] = await Promise.all([
@@ -24,8 +29,8 @@ export function useAIModelSettingsController({
])
if (!list.success) return dispatch({ type: 'ERROR', error: list.error || '供应商配置读取失败' })
dispatch({ type: 'LOADED', providers: list.providers, runtime })
onRuntimeChange(runtime)
}, [onRuntimeChange])
onRuntimeChangeRef.current(runtime)
}, [])
useEffect(() => {
void refresh()
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react'
import type { AIRuntimeModelConfig } from '../../../../../shared/ai-provider'
import { AIProviderCard } from '../ai-model/AIProviderCard'
import { AIProviderEditor } from '../ai-model/AIProviderEditor'
@@ -12,10 +13,25 @@ export function AIModelPage({
onNotice: (message: string) => void
}): React.ReactElement {
const controller = useAIModelSettingsController({ onRuntimeChange, onNotice })
const shouldRevealNewEditor = useRef(false)
const runtime = controller.state.runtime
const defaultProvider = controller.state.providers.find(
(provider) => provider.id === runtime?.providerId
)
useEffect(() => {
if (!controller.state.editor || !shouldRevealNewEditor.current) return
shouldRevealNewEditor.current = false
const editor = document.getElementById('ai-provider-editor')
editor?.scrollIntoView({ behavior: 'smooth', block: 'start' })
editor?.querySelector<HTMLInputElement>('#ai-provider-name')?.focus({ preventScroll: true })
}, [controller.state.editor])
const openNewProvider = (): void => {
shouldRevealNewEditor.current = true
controller.openNew()
}
return (
<div className="settings-page ai-model-page">
<header className="settings-page-header">
@@ -23,7 +39,7 @@ export function AIModelPage({
<h1>AI </h1>
<p></p>
</div>
<button className="database-key-primary" onClick={controller.openNew}>
<button className="database-key-primary" onClick={openNewProvider}>
</button>
</header>
+356 -156
View File
@@ -11,13 +11,19 @@ import {
SummaryDateRange,
SummaryMessageType
} from '../utils/group-report'
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
import type { GroupDailyReport, GroupReportMetadata } from '../../../shared/group-report'
import type {
ReportImageInsightSummary,
ReportPreparationProgress
} from '../utils/group-report-facts'
import { SelectableReportTemplateId } from '../components/reports/ReportTemplateSelector'
import {
transcribeVoiceMessages as transcribeReportVoiceMessages,
type VoiceTranscriptionProgress
} from '../utils/voice-message-reference'
import type { VoiceModelStatus } from '../../../shared/voice-recognition'
import { resolveMemberName } from '../../../shared/member-names'
import type { ReportModelChoice } from '../../../shared/ai-provider'
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
@@ -29,6 +35,7 @@ export type ReportGenerationPhase =
| 'loadingMessages'
| 'transcribingVoice'
| 'preparingInput'
| 'awaitingImageDecision'
| 'requestingModel'
| 'exportingReport'
| 'success'
@@ -80,6 +87,13 @@ interface UseGroupReportGenerationArgs {
summaryDateRange: SummaryDateRange
summaryMessageTypes: SummaryMessageType[]
modelConfig: AiModelConfig
visionModelConfig?: ReportModelChoice
}
interface PreparedReportContext {
input: Awaited<ReturnType<typeof buildGroupReportInput>>
startedAt: number
logs: ReportGenerationLog[]
}
export interface ReportTaskStep {
@@ -100,6 +114,21 @@ export interface RangeMessageState {
error: string
}
const EMPTY_IMAGE_INSIGHT_SUMMARY: ReportImageInsightSummary = {
total: 0,
succeeded: 0,
failed: 0,
items: [],
failures: []
}
const createStepLog = (label: string, startedAt: Date, endedAt: Date): ReportGenerationLog => ({
label,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: endedAt.getTime() - startedAt.getTime()
})
const withTimeout = async <T>(
promise: Promise<T>,
label: string,
@@ -233,7 +262,8 @@ export function useGroupReportGeneration({
sourceContact,
summaryDateRange,
summaryMessageTypes,
modelConfig
modelConfig,
visionModelConfig
}: UseGroupReportGenerationArgs): {
phase: ReportGenerationPhase
error: string
@@ -242,19 +272,27 @@ export function useGroupReportGeneration({
messageTypeCounts: Record<SummaryMessageType, number>
rangeState: RangeMessageState
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
preparationProgress: ReportPreparationProgress | null
imageInsightSummary: ReportImageInsightSummary
generatedImage: string | null
reportPaths: ReportPaths | null
reportSnapshot: GroupDailyReport | null
reportMetadata: GroupReportMetadata | null
generationMetadata: ReportGenerationMetadata
isGenerating: boolean
generate: () => Promise<void>
retry: () => Promise<void>
retry: (modelOverride?: AiModelConfig) => Promise<void>
continueAfterImageFailures: () => Promise<void>
cancelAfterImageFailures: () => void
canRetryModelStep: boolean
failedAt: string
resetGenerationStatus: () => void
clearError: () => void
closeResult: () => void
copyImage: () => Promise<{ success: boolean; error?: string }>
revealReport: () => Promise<{ success: boolean; error?: string }>
templateId: ReportTemplateId
setTemplateId: (value: ReportTemplateId) => void
templateId: SelectableReportTemplateId
setTemplateId: (value: SelectableReportTemplateId) => void
memberNamePreference: ReportMemberNamePreference
setMemberNamePreference: (value: ReportMemberNamePreference) => void
reportTimeoutSeconds: number
@@ -266,9 +304,18 @@ export function useGroupReportGeneration({
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
useState<VoiceTranscriptionProgress | null>(null)
const [preparationProgress, setPreparationProgress] = useState<ReportPreparationProgress | null>(
null
)
const [imageInsightSummary, setImageInsightSummary] = useState<ReportImageInsightSummary>(
EMPTY_IMAGE_INSIGHT_SUMMARY
)
const [failedAt, setFailedAt] = useState('')
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
const [reportSnapshot, setReportSnapshot] = useState<GroupDailyReport | null>(null)
const [reportMetadata, setReportMetadata] = useState<GroupReportMetadata | null>(null)
const [templateId, setTemplateIdState] = useState<SelectableReportTemplateId>('v1')
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
() => {
const saved = localStorage.getItem('group_report_member_name_preference')
@@ -283,11 +330,15 @@ export function useGroupReportGeneration({
generationLogs: []
})
const rangeRequestIdRef = useRef(0)
const preparedContextRef = useRef<PreparedReportContext | null>(null)
const setMemberNamePreference = useCallback((value: ReportMemberNamePreference): void => {
localStorage.setItem('group_report_member_name_preference', value)
setMemberNamePreferenceState(value)
}, [])
const setTemplateId = useCallback((value: SelectableReportTemplateId): void => {
setTemplateIdState(value)
}, [])
const setReportTimeoutSeconds = useCallback((value: number): void => {
const normalized = Math.max(30, Math.min(1800, Math.round(Number(value) || 300)))
localStorage.setItem('group_report_timeout_seconds', String(normalized))
@@ -298,6 +349,7 @@ export function useGroupReportGeneration({
phase === 'loadingMessages' ||
phase === 'transcribingVoice' ||
phase === 'preparingInput' ||
phase === 'awaitingImageDecision' ||
phase === 'requestingModel' ||
phase === 'exportingReport'
@@ -374,7 +426,13 @@ export function useGroupReportGeneration({
setError('')
setGeneratedImage(null)
setReportPaths(null)
setReportSnapshot(null)
setReportMetadata(null)
setVoiceTranscriptionProgress(null)
setPreparationProgress(null)
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
setFailedAt('')
preparedContextRef.current = null
setGenerationMetadata({ generationLogs: [] })
}, [])
@@ -396,45 +454,241 @@ export function useGroupReportGeneration({
[]
)
const runPreparedReport = useCallback(
async (context: PreparedReportContext, selectedModel: AiModelConfig): Promise<void> => {
if (!selectedModel.configured || !selectedModel.model) {
setFailedAt('调用模型生成内容')
setError('请选择一个已配置的 AI 模型')
setPhase('error')
return
}
let currentFailedAt = '调用模型生成内容'
const pushLog = (log: ReportGenerationLog): void => {
context.logs.push(log)
setGenerationMetadata({
modelName: selectedModel.modelName || selectedModel.model,
generationLogs: [...context.logs]
})
}
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
const startedAt = new Date()
try {
return await task()
} finally {
pushLog(createStepLog(label, startedAt, new Date()))
}
}
setError('')
setFailedAt('')
setPhase('requestingModel')
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
setGenerationMetadata({
modelName: selectedModel.modelName || selectedModel.model,
generationLogs: [...context.logs]
})
writeReportLog('info', '调用模型生成日报内容', {
providerName: selectedModel.providerName,
model: selectedModel.model,
reusedPreparedInput: true,
imageInsights: context.input.imageInsightSummary.succeeded
})
try {
const aiMessages = [
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: context.input.prompt }
]
const result = await trackStep(`AI 生成(${selectedModel.model}`, () =>
withTimeout(
window.api.aiChat(aiMessages, {
providerId: selectedModel.providerId,
modelId: selectedModel.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 生成日报',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
writeReportLog('info', '模型响应完成', {
outputLength: result.data.length,
usage: result.usage
})
let tokenUsage =
result.usage && result.usage.total
? result.usage
: estimateTokenUsage(aiMessages, result.data)
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseGroupDailyReport(
result.data,
context.input.topSpeakers,
context.input.activeTimeline,
context.input.voiceLeaderboard || [],
context.input.metadata,
context.input.media
)
} catch (parseError) {
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
...jsonErrorContext(result.data, parseError),
retry: 1
})
const repairMessages = [
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
{ role: 'user', content: result.data }
]
const repairResult = await trackStep(`AI 修复 JSON${selectedModel.model}`, () =>
withTimeout(
window.api.aiChat(repairMessages, {
providerId: selectedModel.providerId,
modelId: selectedModel.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 修复日报 JSON',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!repairResult.success || !repairResult.data) {
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', {
cause: parseError
})
}
const repairUsage =
repairResult.usage && repairResult.usage.total
? repairResult.usage
: estimateTokenUsage(repairMessages, repairResult.data)
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
try {
report = parseGroupDailyReport(
repairResult.data,
context.input.topSpeakers,
context.input.activeTimeline,
context.input.voiceLeaderboard || [],
context.input.metadata,
context.input.media
)
writeReportLog('info', '模型已纠正日报 JSON', {
retry: 1,
outputLength: repairResult.data.length
})
} catch (retryParseError) {
writeReportLog(
'error',
'日报 JSON 重试后仍解析失败',
jsonErrorContext(repairResult.data, retryParseError)
)
throw retryParseError
}
}
setPhase('exportingReport')
currentFailedAt = '导出 HTML 与 PNG'
const exported = await withTimeout(
window.api.exportGroupReport({
report,
metadata: context.input.metadata,
templateId
}),
'日报图片导出'
)
if (
!exported.success ||
!exported.imageDataUrl ||
!exported.htmlPath ||
!exported.pngPath
) {
throw new Error(exported.error || '日报文件生成失败')
}
if (exported.exportTimings?.html) {
pushLog({ label: 'HTML 导出', ...exported.exportTimings.html })
}
if (exported.exportTimings?.png) {
pushLog({ label: 'PNG 导出', ...exported.exportTimings.png })
}
const exportFinishedAt =
exported.exportTimings?.png?.endedAt || exported.exportTimings?.html?.endedAt
const exportFinishTime = exportFinishedAt ? Date.parse(exportFinishedAt) : Date.now()
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
setReportSnapshot(report)
setReportMetadata(context.input.metadata)
setGenerationMetadata({
durationMs:
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
? exportFinishTime - context.startedAt
: Date.now() - context.startedAt,
modelName: selectedModel.modelName || selectedModel.model,
tokenUsage,
generationLogs: [...context.logs]
})
preparedContextRef.current = null
setPreparationProgress(null)
setPhase('success')
writeReportLog('info', '群聊日报生成成功', {
durationMs: Date.now() - context.startedAt,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath,
providerName: selectedModel.providerName,
model: selectedModel.model
})
} catch (generateError) {
const message = errorMessage(generateError)
writeReportLog('error', '群聊日报生成失败', {
error: message,
failedAt: currentFailedAt,
durationMs: Date.now() - context.startedAt,
reusablePreparedInput: currentFailedAt === '调用模型生成内容'
})
setFailedAt(currentFailedAt)
setError(message)
setPhase('error')
}
},
[reportTimeoutSeconds, templateId]
)
const generate = useCallback(async (): Promise<void> => {
if (isGenerating) return
if (!sourceContact) {
setFailedAt('初始化')
setPhase('error')
setError('请先选择一个群聊')
return
}
if (!isGroupContact(sourceContact)) {
setFailedAt('初始化')
setPhase('error')
setError('AI 群聊日报仅支持群聊')
return
}
if (!modelConfig.configured) {
setFailedAt('调用模型生成内容')
setPhase('error')
setError('尚未配置可用的默认 AI 模型')
return
}
if (!summaryMessageTypes.length) {
setFailedAt('初始化')
setPhase('error')
setError('请至少选择一种消息类型')
return
}
const startGenerateTime = Date.now()
let failedAt = '初始化'
let currentFailedAt = '初始化'
const logs: ReportGenerationLog[] = []
const pushLog = (log: ReportGenerationLog): void => {
logs.push(log)
setGenerationMetadata({
modelName: modelConfig.model,
modelName: modelConfig.modelName || modelConfig.model,
generationLogs: [...logs]
})
}
const createStepLog = (label: string, startedAt: Date, endedAt: Date): ReportGenerationLog => ({
label,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: endedAt.getTime() - startedAt.getTime()
})
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
const startedAt = new Date()
try {
@@ -444,11 +698,18 @@ export function useGroupReportGeneration({
}
}
preparedContextRef.current = null
setError('')
setFailedAt('')
setGeneratedImage(null)
setReportPaths(null)
setReportSnapshot(null)
setReportMetadata(null)
setVoiceTranscriptionProgress(null)
setPreparationProgress(null)
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
setGenerationMetadata({
modelName: modelConfig.model,
modelName: modelConfig.modelName || modelConfig.model,
generationLogs: []
})
writeReportLog('info', '开始生成群聊日报', {
@@ -461,9 +722,8 @@ export function useGroupReportGeneration({
})
try {
failedAt = '读取聊天记录'
currentFailedAt = '读取聊天记录'
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
@@ -473,7 +733,7 @@ export function useGroupReportGeneration({
})
setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput')
failedAt = '整理日报输入'
currentFailedAt = '整理日报输入'
const input = await trackStep('整理输入', async () => {
const messagesWithTranscripts = selectedTypes.has('语音')
? await transcribeSelectedVoiceMessages(filteredMessages)
@@ -484,150 +744,41 @@ export function useGroupReportGeneration({
messagesWithTranscripts,
memberNamePreference
)
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
})
setPhase('requestingModel')
failedAt = '调用模型生成内容'
const aiMessages = [
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
]
const result = await trackStep('AI 生成', () =>
withTimeout(
window.api.aiChat(aiMessages, {
providerId: modelConfig.providerId,
modelId: modelConfig.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 生成日报',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
writeReportLog('info', '模型响应完成', {
outputLength: result.data.length,
usage: result.usage
})
let tokenUsage =
result.usage && result.usage.total
? result.usage
: estimateTokenUsage(aiMessages, result.data)
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard || [],
input.metadata,
input.media
)
} catch (parseError) {
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
...jsonErrorContext(result.data, parseError),
retry: 1
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
onProgress: setPreparationProgress,
visionModel: visionModelConfig
})
const repairMessages = [
{
role: 'system',
content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT
},
{ role: 'user', content: result.data }
]
const repairResult = await trackStep('AI 修复 JSON', () =>
withTimeout(
window.api.aiChat(repairMessages, {
providerId: modelConfig.providerId,
modelId: modelConfig.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 修复日报 JSON',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!repairResult.success || !repairResult.data) {
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', { cause: parseError })
}
const repairUsage =
repairResult.usage && repairResult.usage.total
? repairResult.usage
: estimateTokenUsage(repairMessages, repairResult.data)
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
try {
report = parseGroupDailyReport(
repairResult.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard || [],
input.metadata,
input.media
)
writeReportLog('info', '模型已纠正日报 JSON', {
retry: 1,
outputLength: repairResult.data.length
})
} catch (retryParseError) {
writeReportLog(
'error',
'日报 JSON 重试后仍解析失败',
jsonErrorContext(repairResult.data, retryParseError)
)
throw retryParseError
}
}
setPhase('exportingReport')
failedAt = '导出 HTML 与 PNG'
const exported = await withTimeout(
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
'日报图片导出'
)
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败')
}
if (exported.exportTimings?.html) {
pushLog({
label: 'HTML 导出',
...exported.exportTimings.html
})
}
if (exported.exportTimings?.png) {
pushLog({
label: 'PNG 导出',
...exported.exportTimings.png
})
}
const exportFinishedAt =
exported.exportTimings?.png?.endedAt || exported.exportTimings?.html?.endedAt
const exportFinishTime = exportFinishedAt ? Date.parse(exportFinishedAt) : Date.now()
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
setGenerationMetadata({
durationMs:
Number.isFinite(exportFinishTime) && exportFinishTime > startGenerateTime
? exportFinishTime - startGenerateTime
: Date.now() - startGenerateTime,
modelName: modelConfig.model,
tokenUsage,
generationLogs: [...logs]
})
setPhase('success')
writeReportLog('info', '群聊日报生成成功', {
durationMs: Date.now() - startGenerateTime,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath
setImageInsightSummary(input.imageInsightSummary)
writeReportLog('info', '日报输入整理完成', {
imageCandidates: input.imageInsightSummary.total,
imageInsightSucceeded: input.imageInsightSummary.succeeded,
imageInsightFailed: input.imageInsightSummary.failed,
imageInsightsInjectedIntoPrompt:
input.imageInsightSummary.succeeded > 0 && input.prompt.includes('AI 图片识别摘要:')
})
const context: PreparedReportContext = { input, startedAt: startGenerateTime, logs }
preparedContextRef.current = context
if (input.imageInsightSummary.failed > 0) {
setPreparationProgress({
stage: 'summarizingInput',
label: '等待确认是否继续文字总结',
completed: input.imageInsightSummary.succeeded,
total: input.imageInsightSummary.total
})
setPhase('awaitingImageDecision')
return
}
await runPreparedReport(context, modelConfig)
} catch (generateError) {
const message = errorMessage(generateError)
writeReportLog('error', '群聊日报生成失败', {
error: message,
failedAt,
failedAt: currentFailedAt,
durationMs: Date.now() - startGenerateTime
})
setFailedAt(currentFailedAt)
setError(message)
setPhase('error')
}
@@ -636,16 +787,57 @@ export function useGroupReportGeneration({
loadRangeMessages,
memberNamePreference,
modelConfig,
reportTimeoutSeconds,
runPreparedReport,
sourceContact,
summaryDateRange,
summaryMessageTypes,
templateId,
transcribeSelectedVoiceMessages
transcribeSelectedVoiceMessages,
visionModelConfig
])
const retry = useCallback(
async (modelOverride?: AiModelConfig): Promise<void> => {
const context = preparedContextRef.current
if (failedAt === '调用模型生成内容' && context) {
await runPreparedReport(context, modelOverride || modelConfig)
return
}
await generate()
},
[failedAt, generate, modelConfig, runPreparedReport]
)
const continueAfterImageFailures = useCallback(async (): Promise<void> => {
const context = preparedContextRef.current
if (!context || phase !== 'awaitingImageDecision') return
writeReportLog('warn', '用户选择忽略图片识别失败并继续文字总结', {
imageInsightSucceeded: context.input.imageInsightSummary.succeeded,
imageInsightFailed: context.input.imageInsightSummary.failed
})
await runPreparedReport(context, modelConfig)
}, [modelConfig, phase, runPreparedReport])
const cancelAfterImageFailures = useCallback((): void => {
const summary = preparedContextRef.current?.input.imageInsightSummary
preparedContextRef.current = null
setError('')
setFailedAt('')
setPreparationProgress(null)
setPhase('idle')
writeReportLog('warn', '用户因图片识别失败停止本次日报生成', {
imageInsightSucceeded: summary?.succeeded || 0,
imageInsightFailed: summary?.failed || 0
})
}, [])
const canRetryModelStep =
phase === 'error' && failedAt === '调用模型生成内容' && Boolean(preparedContextRef.current)
const clearError = useCallback((): void => {
setError('')
setFailedAt('')
preparedContextRef.current = null
setPhase('idle')
}, [])
@@ -671,12 +863,20 @@ export function useGroupReportGeneration({
messageTypeCounts,
rangeState,
voiceTranscriptionProgress,
preparationProgress,
imageInsightSummary,
generatedImage,
reportPaths,
reportSnapshot,
reportMetadata,
generationMetadata,
isGenerating,
generate,
retry: generate,
retry,
continueAfterImageFailures,
cancelAfterImageFailures,
canRetryModelStep,
failedAt,
resetGenerationStatus,
clearError,
closeResult,
+205
View File
@@ -467,12 +467,57 @@
gap: 12px;
}
.report-model-summary-content {
min-width: 0;
flex: 1;
}
.report-model-selects {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 8px;
}
.report-model-selects label {
display: grid;
min-width: 0;
gap: 5px;
color: var(--wxex-text-secondary);
font: 12px/17px var(--wxex-font);
}
.report-model-selects select {
width: 100%;
min-width: 0;
height: 34px;
padding: 0 30px 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
.report-model-selects select:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.75;
}
.report-model-summary p {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font: 13px/18px var(--wxex-font);
}
.report-model-summary small {
display: block;
margin-top: 6px;
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
.report-model-summary button,
.report-result-actions button,
.report-task-error button {
@@ -649,6 +694,166 @@
font: 12px/18px var(--wxex-font);
}
.report-preparation-progress,
.report-image-insights,
.report-image-decision {
margin: 0 18px 14px;
padding: 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-main);
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-preparation-progress > div {
display: flex;
justify-content: space-between;
gap: 10px;
}
.report-preparation-progress strong,
.report-image-insights summary,
.report-image-decision b {
color: var(--wxex-text-primary);
font-weight: 700;
}
.report-preparation-progress progress {
display: block;
width: 100%;
height: 6px;
margin-top: 9px;
}
.report-image-insights {
padding: 0;
overflow: hidden;
}
.report-image-insights summary {
padding: 11px 12px;
cursor: pointer;
user-select: none;
}
.report-image-insight-list {
display: grid;
max-height: 300px;
overflow-y: auto;
border-top: 1px solid var(--wxex-border);
}
.report-image-insight-list article {
display: grid;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid var(--wxex-border);
}
.report-image-insight-list article:last-child {
border-bottom: 0;
}
.report-image-insight-list article.failed {
background: #fff7f3;
}
.report-image-insight-list article > div {
display: flex;
justify-content: space-between;
gap: 8px;
}
.report-image-insight-list b {
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.report-image-insight-list time,
.report-image-insight-list small {
color: var(--wxex-text-muted);
}
.report-image-insight-list p,
.report-image-decision p {
margin: 0;
overflow-wrap: anywhere;
}
.report-image-decision {
border-color: rgba(197, 137, 48, 0.4);
background: #fffaf0;
}
.report-image-decision p {
margin: 5px 0 10px;
}
.report-image-decision > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.report-image-decision button,
.report-model-retry button {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.report-image-decision button:first-child,
.report-model-retry button {
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.report-model-retry {
display: grid;
gap: 7px;
margin-bottom: 10px;
}
.report-model-retry label {
color: var(--wxex-text-primary);
font-weight: 700;
}
.report-model-retry select {
width: 100%;
min-width: 0;
min-height: 34px;
padding: 0 9px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
font: 12px/18px var(--wxex-font);
}
.report-model-retry small {
color: var(--wxex-text-secondary);
}
.report-model-retry small.error {
color: var(--wxex-danger);
}
.report-model-retry button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.report-voice-progress {
margin-top: 14px;
padding: 12px;
+568 -6
View File
@@ -245,20 +245,30 @@
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-viewer-header > div:first-child {
flex: 1 1 220px;
min-width: 180px;
}
.report-viewer-header h1 {
font: 700 20px/27px var(--wxex-font);
word-break: keep-all;
}
.report-viewer-toolbar {
display: flex;
flex: 0 0 auto;
flex: 0 1 auto;
min-width: 0;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
@@ -292,6 +302,63 @@
position: relative;
}
.report-template-switch-menu {
position: relative;
}
.report-template-switch-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 9;
width: 260px;
padding: 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
}
.report-template-switch-popover p {
margin: 0 4px 6px;
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.report-template-switch-popover button {
display: grid;
width: 100%;
min-height: 42px;
grid-template-columns: 62px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
}
.report-template-switch-popover button:hover,
.report-template-switch-popover button.active {
background: var(--wxex-ai-soft);
}
.report-template-switch-popover button span,
.report-template-switch-popover button i {
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
font-style: normal;
}
.report-template-switch-popover button b {
overflow: hidden;
color: var(--wxex-text-primary);
font: 600 13px/18px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
.report-more-popover {
position: absolute;
top: 38px;
@@ -533,6 +600,15 @@
.report-viewer-header {
flex-direction: column;
}
.report-viewer-header > div:first-child {
width: 100%;
}
.report-viewer-toolbar {
width: 100%;
justify-content: flex-start;
}
}
/* ============================================================ */
@@ -550,6 +626,23 @@
gap: 10px;
}
.report-template-catalog,
.report-template-group {
display: grid;
gap: 12px;
}
.report-template-catalog {
gap: 18px;
}
.report-template-group-title {
color: var(--wxex-text-muted);
font: 700 11px/16px var(--wxex-font);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.report-template-item {
display: flex;
align-items: center;
@@ -582,6 +675,14 @@
min-width: 0;
}
.report-template-eyebrow {
margin-bottom: 2px;
color: var(--wxex-brand);
font: 800 10px/14px var(--wxex-font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.report-template-item input[type='radio'] {
accent-color: var(--wxex-primary, #07c160);
}
@@ -597,6 +698,190 @@
color: var(--wxex-text-primary, #1f2933);
}
.report-template-diagram {
display: grid;
width: 72px;
height: 54px;
flex: 0 0 auto;
gap: 4px;
overflow: hidden;
padding: 6px;
border: 1px solid #dce5df;
border-radius: 8px;
background: #f8faf8;
}
.diagram-masthead,
.diagram-kpis,
.diagram-content,
.diagram-column {
display: grid;
min-width: 0;
gap: 2px;
}
.diagram-masthead {
grid-template-columns: 1fr 18px;
}
.diagram-masthead i,
.diagram-masthead b,
.diagram-masthead span,
.diagram-kpis i,
.diagram-column b,
.diagram-column span {
display: block;
min-width: 0;
min-height: 2px;
border-radius: 2px;
background: #b9c9c0;
}
.diagram-masthead i {
height: 4px;
background: #278861;
}
.diagram-masthead b {
height: 4px;
}
.diagram-masthead span {
grid-column: 1 / -1;
width: 72%;
height: 3px;
}
.diagram-kpis {
grid-template-columns: repeat(4, 1fr);
}
.diagram-kpis i {
height: 7px;
background: #dceae2;
}
.diagram-content {
grid-template-columns: 1fr;
}
.diagram-column b {
height: 8px;
background: #8ab6a0;
}
.diagram-column span {
height: 4px;
background: #d5dfd9;
}
.diagram-mobile-feed .diagram-content {
grid-template-columns: 1fr;
}
.diagram-v1 {
background: #f5f7f8;
}
.diagram-v1 .diagram-masthead i {
background: #3b82f6;
}
.diagram-v1 .diagram-kpis i {
background: #e5edf6;
}
.diagram-v1 .diagram-column-b,
.diagram-v1 .diagram-column-c {
display: none;
}
.diagram-mobile-feed .diagram-column-b,
.diagram-mobile-feed .diagram-column-c {
display: none;
}
.diagram-mobile-magazine {
border-radius: 2px;
background: #fbf7ef;
}
.diagram-mobile-magazine .diagram-masthead i {
height: 7px;
background: #2c2925;
}
.diagram-mobile-magazine .diagram-kpis i {
border-radius: 0;
background: transparent;
border-top: 1px solid #574f45;
border-bottom: 1px solid #574f45;
}
.diagram-mobile-magazine .diagram-column-b,
.diagram-mobile-magazine .diagram-column-c {
display: none;
}
.diagram-mobile-dashboard {
border-color: #30453b;
border-radius: 3px;
background: #13201b;
}
.diagram-mobile-dashboard .diagram-masthead i,
.diagram-mobile-dashboard .diagram-column b {
background: #55e69a;
}
.diagram-mobile-dashboard .diagram-kpis i {
border: 1px solid #30453b;
border-radius: 2px;
background: #1c2d26;
}
.diagram-mobile-dashboard .diagram-column span {
background: #385347;
}
.diagram-mobile-dashboard .diagram-column-b,
.diagram-mobile-dashboard .diagram-column-c {
display: none;
}
.diagram-desktop-workspace {
width: 90px;
}
.diagram-desktop-workspace .diagram-content {
grid-template-columns: 0.72fr 1.45fr 0.8fr;
}
.diagram-desktop-editorial {
width: 90px;
border-radius: 2px;
background: #fbf6eb;
}
.diagram-desktop-editorial .diagram-masthead i {
height: 7px;
border-radius: 0;
background: #332b23;
}
.diagram-desktop-editorial .diagram-content {
grid-template-columns: 1.7fr 0.8fr;
}
.diagram-desktop-editorial .diagram-column-c {
display: none;
}
.diagram-desktop-editorial .diagram-column b {
border-radius: 0;
background: #9a4b38;
}
.report-template-tagline {
margin-top: 4px;
font-size: 12px;
@@ -641,12 +926,80 @@
background: #f3f5f7;
border-radius: 18px;
padding: 18px;
width: min(380px, 100%);
width: min(520px, 100%);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
}
.report-template-preview-card > .report-template-diagram {
width: 100%;
height: 260px;
padding: 18px;
gap: 10px;
border-radius: 12px;
}
.report-template-preview-card > .diagram-v1,
.report-template-preview-card > .diagram-mobile-feed,
.report-template-preview-card > .diagram-mobile-magazine,
.report-template-preview-card > .diagram-mobile-dashboard {
width: 180px;
justify-self: center;
}
.report-template-preview-card > .report-template-diagram .diagram-masthead,
.report-template-preview-card > .report-template-diagram .diagram-kpis,
.report-template-preview-card > .report-template-diagram .diagram-content,
.report-template-preview-card > .report-template-diagram .diagram-column {
gap: 6px;
}
.report-template-preview-card > .report-template-diagram .diagram-masthead i,
.report-template-preview-card > .report-template-diagram .diagram-masthead b {
height: 13px;
}
.report-template-preview-card > .report-template-diagram .diagram-masthead span {
height: 7px;
}
.report-template-preview-card > .report-template-diagram .diagram-kpis i {
height: 30px;
}
.report-template-preview-card > .report-template-diagram .diagram-column b {
height: 45px;
}
.report-template-preview-card > .report-template-diagram .diagram-column span {
height: 20px;
}
.report-template-preview-heading,
.report-template-preview-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.report-template-preview-heading span,
.report-template-preview-heading em {
color: var(--wxex-text-muted);
font: 700 10px/14px var(--wxex-font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.report-template-preview-heading em {
padding: 4px 7px;
border-radius: 999px;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-style: normal;
}
.report-template-preview-card h4 {
margin: 0 0 6px;
font-size: 17px;
@@ -710,10 +1063,19 @@
color: #1f2933;
}
.report-template-preview-close {
.report-template-preview-note {
margin: 14px 0 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-template-preview-actions {
justify-content: flex-end;
margin-top: 14px;
width: 100%;
padding: 10px;
}
.report-template-preview-actions button {
padding: 9px 14px;
border-radius: 10px;
border: none;
background: var(--wxex-primary, #07c160);
@@ -722,6 +1084,12 @@
cursor: pointer;
}
.report-template-preview-actions button.secondary {
border: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-primary);
}
.settings-auto-login-card {
padding: 18px 20px;
}
@@ -818,4 +1186,198 @@
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
.wechat-share-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(20, 31, 27, 0.46);
backdrop-filter: blur(8px);
}
.wechat-share-dialog {
width: min(540px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
border: 1px solid rgba(95, 129, 116, 0.2);
border-radius: 22px;
background: #fff;
box-shadow: 0 28px 90px rgba(14, 32, 25, 0.24);
color: #17241f;
font-family: var(--wxex-font);
}
.wechat-share-dialog,
.wechat-share-dialog * {
box-sizing: border-box;
}
.wechat-share-dialog > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 26px 18px;
border-bottom: 1px solid #e8eeeb;
}
.wechat-share-dialog h2,
.wechat-share-dialog h3,
.wechat-share-dialog p {
margin: 0;
}
.wechat-share-dialog header h2 {
color: #17241f;
font-size: 20px;
}
.wechat-share-dialog header p {
margin-top: 6px;
color: #718078;
font-size: 13px;
}
.wechat-share-dialog-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: #f1f5f3;
color: #5f6d67;
font-size: 22px;
line-height: 1;
}
.wechat-share-form {
display: grid;
gap: 18px;
padding: 24px 26px 26px;
}
.wechat-share-form label {
display: grid;
gap: 8px;
}
.wechat-share-form label > span {
color: #33463e;
font-size: 13px;
font-weight: 650;
}
.wechat-share-form input,
.wechat-share-form textarea {
min-width: 0;
max-width: 100%;
width: 100%;
border: 1px solid #cfdbd6;
border-radius: 11px;
background: #fbfdfc;
padding: 11px 13px;
color: #1f2c27;
font: inherit;
outline: none;
}
.wechat-share-form input:focus,
.wechat-share-form textarea:focus {
border-color: #32a978;
box-shadow: 0 0 0 3px rgba(50, 169, 120, 0.12);
}
.wechat-share-service-config {
min-width: 0;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d6e8df;
border-radius: 14px;
background: #f2faf6;
}
.wechat-share-service-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
padding: 13px 15px;
border: 1px solid #dce6e1;
border-radius: 12px;
background: #f7faf8;
}
.wechat-share-service-summary > div {
display: grid;
min-width: 0;
gap: 3px;
}
.wechat-share-service-summary span {
color: #728078;
font-size: 11px;
}
.wechat-share-service-summary b {
overflow: hidden;
color: #2c4138;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.wechat-share-service-summary button {
flex: 0 0 auto;
min-height: 30px;
padding: 0 11px;
}
.wechat-share-service-config h3 {
color: #23523f;
font-size: 14px;
}
.wechat-share-service-config p,
.wechat-share-privacy {
color: #68776f;
font-size: 12px;
line-height: 1.6;
}
.wechat-share-privacy {
padding: 12px 14px;
border-radius: 10px;
background: #f5f7f6;
}
.wechat-share-form > footer,
.wechat-share-success > div {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.wechat-share-form button,
.wechat-share-success button {
min-height: 38px;
padding: 0 17px;
border: 1px solid #ced9d4;
border-radius: 10px;
background: #fff;
color: #34463f;
}
.wechat-share-form button.primary,
.wechat-share-success button.primary {
border-color: #15945e;
background: #15945e;
color: #fff;
}
.wechat-share-form button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.wechat-share-success {
display: grid;
justify-items: center;
gap: 11px;
padding: 28px;
text-align: center;
}
.wechat-share-success > img {
width: 260px;
max-width: 80%;
border: 10px solid #fff;
border-radius: 14px;
box-shadow: 0 10px 35px rgba(27, 59, 46, 0.12);
}
.wechat-share-success h3 {
color: #203029;
font-size: 18px;
}
.wechat-share-success p,
.wechat-share-success small {
color: #6c7b74;
line-height: 1.6;
}
.wechat-share-success > div {
margin-top: 12px;
}
+10
View File
@@ -1777,6 +1777,16 @@
white-space: nowrap;
font-weight: 400;
}
.ai-model-row .ai-model-identity-field {
display: grid;
min-width: 0;
gap: 5px;
justify-self: stretch;
color: #66706b;
font-family: inherit;
font-size: 11px;
font-weight: 600;
}
.ai-provider-advanced,
.ai-provider-preview {
border: 1px solid #e3e8e5;
+165 -55
View File
@@ -3,7 +3,6 @@ import {
GroupDailyReport,
GroupReportMetadata,
ReportFunBadge,
ReportMediaGalleryItem,
ReportMode,
ReportSpeakerRank,
ReportVisionGalleryItem,
@@ -16,6 +15,8 @@ import type {
ImageCandidate,
ImageCandidateQuery
} from '../../../shared/image-insight'
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
import type { ReportModelChoice } from '../../../shared/ai-provider'
interface ReportImageReadResult {
success: boolean
@@ -57,6 +58,43 @@ export interface GroupReportFactsSnapshot {
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
factsPrompt: string
imageInsightSummary: ReportImageInsightSummary
}
export interface ReportImageInsightItem {
messageId: string
sender: string
time: string
description: string
ocrText?: string
tags: string[]
}
export interface ReportImageInsightFailure {
messageId?: string
sender: string
time?: string
error: string
}
export interface ReportImageInsightSummary {
total: number
succeeded: number
failed: number
items: ReportImageInsightItem[]
failures: ReportImageInsightFailure[]
}
export interface ReportPreparationProgress {
stage: 'selectingImages' | 'recognizingImages' | 'summarizingInput'
label: string
completed?: number
total?: number
}
export interface BuildGroupReportFactsOptions {
onProgress?: (progress: ReportPreparationProgress) => void
visionModel?: ReportModelChoice
}
function friendlyImageNotice(warnings: string[]): string {
@@ -180,12 +218,14 @@ const buildImageContext = (
stats: string
responseCount: number
participantCount: number
interactionCount: number
snippets: string[]
} => {
const baseTime = parseTimestamp(messages[index])
const participants = new Set<string>()
const snippets: string[] = []
let responseCount = 0
let interactionCount = 0
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
const candidate = messages[offset]
@@ -200,6 +240,9 @@ const buildImageContext = (
responseCount += 1
participants.add(sender)
}
if (candidate.contentData?.type === 'sticker' || candidate.contentData?.type === 'voice') {
interactionCount += 1
}
if (
snippets.length < 3 &&
!content.startsWith('[图片]') &&
@@ -226,6 +269,7 @@ const buildImageContext = (
stats: statsParts.join(' · '),
responseCount,
participantCount: participants.size,
interactionCount,
snippets
}
}
@@ -234,13 +278,17 @@ const buildMediaSection = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
topSpeakersMap: Map<string, number>
topSpeakersMap: Map<string, number>,
options: BuildGroupReportFactsOptions = {}
): Promise<{
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
warnings: string[]
imageInsightSummary: ReportImageInsightSummary
}> => {
const warnings: string[] = []
const imageFailures: ReportImageInsightFailure[] = []
let imageCandidateTotal = 0
const rendererApi = typeof window === 'undefined' ? null : window.api
const rawImageCandidates = messages
.map((message, index) => {
@@ -258,21 +306,31 @@ const buildMediaSection = async (
stats: context.stats,
replyCount: context.responseCount,
participantCount: context.participantCount,
score: context.responseCount * 3 + context.participantCount * 2 + 1
interactionCount: context.interactionCount,
score: calculateImageHeatScore({
responseCount: context.responseCount,
interactionCount: context.interactionCount
}),
isHot: isHotImageCandidate({
responseCount: context.responseCount,
interactionCount: context.participantCount
})
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.filter((item) => item.isHot)
.sort((left, right) => right.score - left.score)
.slice(0, 6)
// ============================================================
// AI 图片理解(ImageInsightService 接入)
// 通过 main 进程拿 Top 3 热点图 + 已缓存的 Insight;未缓存的并发调 AI
// 失败不阻塞:任何错误只记日志,降级到原 gallery
// 通过 main 进程拿最多 3 张真正的热点图 + 已缓存的 Insight;
// 未缓存的并发调 AI。失败不阻塞文字日报。
// ============================================================
let visionGallery: ReportVisionGalleryItem[] = []
try {
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
options.onProgress?.({ stage: 'selectingImages', label: '筛选热点图片' })
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
@@ -288,7 +346,7 @@ const buildMediaSection = async (
sender: c.sender,
sentAt: parseTimestamp(srcMsg),
responseCount: c.replyCount || 0,
interactionCount: c.participantCount || 0
interactionCount: c.interactionCount || 0
}
})
@@ -300,12 +358,45 @@ const buildMediaSection = async (
inputs: imageInputs
})
const candidates = candidatesResp.success ? candidatesResp.candidates : []
imageCandidateTotal = candidates.length
console.log('[buildMediaSection] imageListCandidates returned', candidates.length, 'candidates')
if (!candidatesResp.success && rawImageCandidates.length) {
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
imageFailures.push({
messageId: candidate.sourceMessageIds[0],
sender: candidate.sender,
time: candidate.time,
error: candidatesResp.error || '热点图片筛选失败'
})
}
}
options.onProgress?.({
stage: 'recognizingImages',
label: candidates.length ? '识别图片中' : '未找到可识别的热点图片',
completed: 0,
total: candidates.length
})
// 对每个候选:缓存命中直接用,未命中并发调 imageAnalyze
let analyzedCount = 0
const analyzed = await Promise.all(
candidates.map(async (candidate) => {
if (candidate.insight) return candidate.insight
const finish = (): void => {
analyzedCount += 1
options.onProgress?.({
stage: 'recognizingImages',
label: '识别图片中',
completed: analyzedCount,
total: candidates.length
})
}
if (candidate.insight) {
finish()
return candidate.insight
}
// 未命中:解密图片拿 base64 → 调 AI
try {
const img = await rendererApi.getImage(
@@ -318,6 +409,12 @@ const buildMediaSection = async (
warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片读取失败:${img.error || '未知错误'}`
)
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: img.error || '图片读取失败'
})
return null
}
const analyzeResp = await rendererApi.imageAnalyze({
@@ -327,12 +424,20 @@ const buildMediaSection = async (
sender: candidate.sender,
sentAt: candidate.sentAt,
sessionId: candidate.sessionId,
providerId: options.visionModel?.providerId,
modelId: options.visionModel?.model,
force: false
})
if (!analyzeResp.success || !analyzeResp.insight) {
warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别失败:${analyzeResp.error || '模型未返回识别结果'}`
)
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: analyzeResp.error || '模型未返回识别结果'
})
return null
}
return analyzeResp.insight
@@ -341,7 +446,15 @@ const buildMediaSection = async (
warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别异常:${error instanceof Error ? error.message : String(error)}`
)
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: error instanceof Error ? error.message : String(error)
})
return null
} finally {
finish()
}
})
)
@@ -382,45 +495,26 @@ const buildMediaSection = async (
} catch (error) {
console.warn('[buildMediaSection] vision flow failed, fallback to empty:', error)
warnings.push(`图片识别流程失败:${error instanceof Error ? error.message : String(error)}`)
if (!imageFailures.length && rawImageCandidates.length) {
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
imageFailures.push({
messageId: candidate.sourceMessageIds[0],
sender: candidate.sender,
time: candidate.time,
error: error instanceof Error ? error.message : String(error)
})
}
}
visionGallery = []
}
const imageCandidates = rendererApi
? await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId, {
includeData: true
})
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
time: item.time,
imageUrl: result.data,
note: item.note,
stats: item.stats,
inferenceLabel: '基于图片后的聊天上下文推断',
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount,
score: item.score
}
})
)
: []
const gallery: ReportMediaGalleryItem[] = imageCandidates
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => right.score - left.score)
.slice(0, 4)
.map((item) => ({
sender: item.sender,
time: item.time,
imageUrl: item.imageUrl,
note: item.note,
stats: item.stats,
inferenceLabel: item.inferenceLabel,
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount
}))
options.onProgress?.({
stage: 'summarizingInput',
label: '汇总图片识别结果',
completed: visionGallery.length,
total: imageCandidateTotal
})
const voiceMessages = messages
.filter((message) => message.contentData?.type === 'voice')
@@ -495,11 +589,11 @@ const buildMediaSection = async (
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
})
}
if (gallery[0]) {
if (visionGallery[0]) {
funBadges.push({
title: '图片话题王',
owner: gallery[0].sender,
note: `${gallery[0].time}图片带动了最明显的一轮讨论`
owner: visionGallery[0].sender,
note: `${visionGallery[0].time}热点图片进入了 AI 图片精选`
})
}
if (voiceLeaderboard[0]) {
@@ -512,13 +606,28 @@ const buildMediaSection = async (
return {
media: {
gallery,
// 保留旧字段以兼容历史报告,但新日报不再生成或读取“群聊相册”。
gallery: [],
visionGallery,
voiceHighlights: voiceHighlights.slice(0, 2),
funBadges: funBadges.slice(0, 3)
},
voiceLeaderboard,
warnings
warnings,
imageInsightSummary: {
total: imageCandidateTotal,
succeeded: visionGallery.length,
failed: imageFailures.length,
items: visionGallery.map((item) => ({
messageId: item.messageId,
sender: item.sender,
time: item.time,
description: item.description,
ocrText: item.ocrText,
tags: item.tags
})),
failures: imageFailures
}
}
}
@@ -561,7 +670,8 @@ export const buildGroupReportFacts = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
reportMode: ReportMode
reportMode: ReportMode,
options: BuildGroupReportFactsOptions = {}
): Promise<GroupReportFactsSnapshot> => {
const transcriptRows = messages.map((message) => ({
id: message.id,
@@ -665,11 +775,12 @@ export const buildGroupReportFacts = async (
reportMode
}
const { media, voiceLeaderboard, warnings } = await buildMediaSection(
const { media, voiceLeaderboard, warnings, imageInsightSummary } = await buildMediaSection(
messages,
contact,
isGroup,
speakerCounts
speakerCounts,
options
)
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
if (imageCount > 0 && !media.visionGallery?.length) {
@@ -681,6 +792,7 @@ export const buildGroupReportFacts = async (
if (
transcriptRows.length > 0 &&
transcriptRows.every((row) => row.content === '[图片]') &&
imageInsightSummary.total > 0 &&
!media.visionGallery?.length
) {
throw new Error(
@@ -692,9 +804,6 @@ export const buildGroupReportFacts = async (
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
activeTimeline ? `活跃时段:${activeTimeline}` : '',
media.gallery.length
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats}`).join('')}`
: '',
// AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision)
(media.visionGallery?.length ?? 0) > 0
? `AI 图片识别摘要:${(media.visionGallery || [])
@@ -727,6 +836,7 @@ export const buildGroupReportFacts = async (
activeTimeline,
media,
voiceLeaderboard,
factsPrompt
factsPrompt,
imageInsightSummary
}
}
+12 -68
View File
@@ -21,6 +21,7 @@ import {
import { Contact, Message } from '../../../shared/types'
import { jsonrepair } from 'jsonrepair'
import { buildGroupReportFacts } from './group-report-facts'
import type { BuildGroupReportFactsOptions, ReportImageInsightSummary } from './group-report-facts'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
@@ -85,6 +86,7 @@ export interface GroupReportInput {
activeTimeline: string
voiceLeaderboard: ReportVoiceLeaderboardItem[]
media: GroupDailyReport['media']
imageInsightSummary: ReportImageInsightSummary
}
const REPORT_MODE_LABEL: Record<ReportMode, string> = {
@@ -103,7 +105,6 @@ interface ReportModeConfig {
maxStorylines: number
maxReversals: number
maxChains: number
maxGallery: number
maxVoiceHighlights: number
maxBadges: number
maxResources: number
@@ -125,7 +126,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
maxStorylines: 0,
maxReversals: 0,
maxChains: 0,
maxGallery: 0,
maxVoiceHighlights: 0,
maxBadges: 0,
maxResources: 0,
@@ -153,7 +153,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
maxStorylines: 2,
maxReversals: 2,
maxChains: 3,
maxGallery: 4,
maxVoiceHighlights: 2,
maxBadges: 3,
maxResources: 4,
@@ -173,7 +172,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
'storylines',
'reversals',
'vision',
'gallery',
'voices',
'badges',
'chains'
@@ -185,9 +183,10 @@ export const buildGroupReportInput = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
reportMode: ReportMode
reportMode: ReportMode,
options: BuildGroupReportFactsOptions = {}
): Promise<GroupReportInput> => {
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode, options)
const desiredTopicCount =
facts.metadata.messageCount >= 1000
? '建议提炼 6-8 个互不重复的主要话题'
@@ -222,7 +221,8 @@ ${transcript}`
topSpeakers: facts.topSpeakers,
activeTimeline: facts.activeTimeline,
voiceLeaderboard: facts.voiceLeaderboard,
media: facts.media
media: facts.media,
imageInsightSummary: facts.imageInsightSummary
}
}
@@ -531,53 +531,6 @@ const topicLimitForMessageVolume = (
return config
}
const attachHighImpactImage = (
topics: ReportTopic[],
gallery: GroupDailyReport['media']['gallery'],
visionGallery?: GroupDailyReport['media']['visionGallery']
): ReportTopic[] => {
if (!gallery.length && !visionGallery?.length) return topics
// 优先用 visionGallery(AI 真实识别的 description)
const firstVision = visionGallery?.find((it) => it.importance !== 'low')
const [firstImage, ...rest] = gallery
const nextTopics = topics.map((topic, index) => {
if (index !== 0) return topic
if (firstVision) {
// AI 真实识别路径:note 直接用 description,不带"根据推断"前缀
const noteParts = [firstVision.description]
if (firstVision.ocrText) noteParts.push(`文字:${firstVision.ocrText}`)
if (firstVision.tags.length) noteParts.push(`标签:${firstVision.tags.join('/')}`)
return {
...topic,
image: {
note: noteParts.join(' · '),
sourceMessageIds: firstVision.sourceMessageIds
// 注意:不填 imageUrl,因为 unknown imageHash 等问题可能导致 main 取不到原图,
// 让 renderer 在 buildGroupReportFacts 阶段就把 dataUrl 预先加载好塞到 gallery 里,
// 这里走 gallery 路径自然带 imageUrl
}
}
}
if (firstImage?.replyCount && firstImage.replyCount >= 3) {
return {
...topic,
image: {
imageUrl: firstImage.imageUrl,
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
sourceMessageIds: firstImage.sourceMessageIds
}
}
}
return topic
})
if (firstVision) {
// visionGallery 用过的不再展示
return nextTopics
}
gallery.splice(0, rest.length >= 0 ? 1 : 0)
return nextTopics
}
const postProcessReport = (
report: GroupDailyReport,
mode: ReportMode,
@@ -591,12 +544,7 @@ const postProcessReport = (
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.title, item.summary)
)
const gallery = [...report.media.gallery]
const topics = attachHighImpactImage(
clampTopics(topicsDeduped, config),
gallery,
report.media.visionGallery
)
const topics = clampTopics(topicsDeduped, config)
const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
Math.max(item.importance || 0, item.confidence || 0.6)
@@ -784,13 +732,9 @@ const postProcessReport = (
0.72,
0.9
),
gallery: buildSectionMeta(
config.enabledSections.includes('gallery'),
gallery.length,
report.media.gallery.length,
0.6,
0.8
),
// gallery remains in the type for historical reports, but is disabled for
// every newly generated report because AI vision is the single image source.
gallery: buildSectionMeta(false, 0, 0, 0, 0),
voices: buildSectionMeta(
config.enabledSections.includes('voices'),
voiceHighlights.length,
@@ -830,7 +774,7 @@ const postProcessReport = (
participantChains,
keywords,
media: {
gallery,
gallery: [],
visionGallery: report.media.visionGallery,
voiceHighlights,
funBadges
@@ -0,0 +1,63 @@
import type {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportRenderSnapshotExportRequest
} from '../../../shared/group-report'
import type {
GeneratedReportRecord,
PrepareGeneratedReportTemplateSwitchResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../../../shared/report-history'
import type { SelectableReportTemplateId } from '../../../shared/report-templates'
interface ReportTemplateSwitchApi {
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
exportGroupReportSnapshot: (
request: GroupReportRenderSnapshotExportRequest
) => Promise<GroupReportExportResult>
prepareGeneratedReportTemplateSwitch: (
reportId: string
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
updateGeneratedReportTemplate: (
request: UpdateGeneratedReportTemplateRequest
) => Promise<UpdateGeneratedReportTemplateResult>
}
export async function switchGeneratedReportTemplate(
report: GeneratedReportRecord,
templateId: SelectableReportTemplateId,
api: ReportTemplateSwitchApi
): Promise<UpdateGeneratedReportTemplateResult> {
let exported: GroupReportExportResult
if (report.reportSnapshot && report.reportMetadata) {
exported = await api.exportGroupReport({
report: report.reportSnapshot,
metadata: report.reportMetadata,
templateId
})
} else {
const prepared = await api.prepareGeneratedReportTemplateSwitch(report.id)
if (!prepared.success || !prepared.snapshot) {
return {
success: false,
error: prepared.error || '旧报告缺少可迁移的本地内容'
}
}
exported = await api.exportGroupReportSnapshot({
snapshot: prepared.snapshot,
templateId
})
}
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
return { success: false, error: exported.error || '新模板导出失败' }
}
return api.updateGeneratedReportTemplate({
reportId: report.id,
templateId,
generatedImage: exported.imageDataUrl,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath
})
}
+11
View File
@@ -74,6 +74,17 @@ export interface AIRuntimeModelConfig {
timeoutMs?: number
}
/** 日报工作区内可选择的模型,不会修改全局默认 Provider。 */
export interface ReportModelChoice extends AIRuntimeModelConfig {
providerId: string
configured: true
}
export interface AIVisionRuntimeConfig extends AIRuntimeModelConfig {
/** 图片理解模型独立于文字总结模型,由已验证的 vision/ocr capability 自动选择。 */
source: 'default-model' | 'vision-capability' | 'unavailable'
}
export interface AiSearchProviderStatus {
configured: boolean
requiresConsent: boolean
+1
View File
@@ -32,6 +32,7 @@ export interface ExportRequest {
targets: ExportTarget[]
format: ExportFormat
outputName: string
outputDirectory?: string
startTime?: number
endTime?: number
kinds: ExportMessageKind[]
+19 -2
View File
@@ -1,6 +1,8 @@
export type ReportHeat = '高' | '中' | '低'
export type ReportMode = 'compact' | 'full'
import type { ReportTemplateRequestId } from './report-templates'
export const selectHeroParticipantNames = (names: string[]): string[] =>
Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 4)
@@ -283,8 +285,8 @@ export interface GroupReportMetadata {
export interface GroupReportExportRequest {
report: GroupDailyReport
metadata: GroupReportMetadata
/** 模板 ID:'v1' 经典 / 'v2' 当前。缺省或未知值用默认(v2) */
templateId?: 'v1' | 'v2'
/** v1 是默认经典模板,v2 仅保留旧调用兼容;另有五套新版产品模板。 */
templateId?: ReportTemplateRequestId
}
export interface GroupReportExportResult {
@@ -307,3 +309,18 @@ export interface GroupReportExportResult {
warnings?: string[]
error?: string
}
/**
* GroupDailyReport HTML
* values / HTML
*/
export interface GroupReportRenderSnapshot {
groupName: string
reportDate: string
values: Record<string, string>
}
export interface GroupReportRenderSnapshotExportRequest {
snapshot: GroupReportRenderSnapshot
templateId: ReportTemplateRequestId
}
+36 -2
View File
@@ -12,6 +12,21 @@ export type ImageCategory =
export type ImageImportance = 'low' | 'medium' | 'high'
/** 日报图片理解结果最多复用 10 分钟;旧记录保留在磁盘,仅不再作为缓存命中。 */
export const IMAGE_INSIGHT_CACHE_TTL_MS = 10 * 60 * 1000
export const isFreshImageInsight = (
insight: Pick<ImageInsight, 'updatedAt'> | null | undefined,
now = Date.now()
): boolean =>
Boolean(
insight &&
Number.isFinite(insight.updatedAt) &&
insight.updatedAt > 0 &&
now >= insight.updatedAt &&
now - insight.updatedAt < IMAGE_INSIGHT_CACHE_TTL_MS
)
/**
* AI ( image-insights.json)
*
@@ -57,6 +72,9 @@ export interface ImageAnalysisRequest {
sender: string
sentAt: number
sessionId: string
/** 日报局部选择的图片理解模型;未传时仍使用自动视觉路由。 */
providerId?: string
modelId?: string
/** 强制重新分析(忽略缓存) */
force?: boolean
}
@@ -94,7 +112,7 @@ export interface ImageCandidateQuery {
sessionId: string
startTime: number
endTime: number
/** 取 Top N,默认 3 */
/** 最多取 N 张,默认 3;服务端会将其限制在 0–3 */
limit?: number
/** 由 renderer 从已加载消息中提取的图片候选(包含热度信息) */
inputs?: Array<{
@@ -107,4 +125,20 @@ export interface ImageCandidateQuery {
responseCount: number
interactionCount: number
}>
}
}
/**
* A picture is considered hot only when it has observable follow-up activity.
* A bare image message (or a single passive reply) is not enough to enter the
* daily report's AI image selection. This keeps the "top 3" value an upper
* bound rather than a quota that fills every available slot.
*/
export const isHotImageCandidate = (input: {
responseCount: number
interactionCount: number
}): boolean => input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
export const calculateImageHeatScore = (input: {
responseCount: number
interactionCount: number
}): number => input.responseCount * 3 + input.interactionCount * 2 + 1
+41
View File
@@ -1,3 +1,10 @@
import type {
GroupDailyReport,
GroupReportMetadata,
GroupReportRenderSnapshot
} from './group-report'
import type { SelectableReportTemplateId } from './report-templates'
export type ReportAssetStatus = 'ready' | 'missing'
export interface GeneratedReportRecord {
@@ -20,6 +27,10 @@ export interface GeneratedReportRecord {
height: number
}
duration?: number
/** 文字总结模型;modelName 保留为旧记录兼容字段。 */
textModelName?: string
/** 图片理解模型。 */
imageModelName?: string
modelName?: string
tokenUsage?: {
input?: number
@@ -37,6 +48,11 @@ export interface GeneratedReportRecord {
endedAt: string
duration: number
}[]
/** 新版报告保存结构化快照,模板切换时只重新渲染,不再调用 AI。 */
reportSnapshot?: GroupDailyReport
reportMetadata?: GroupReportMetadata
reportRenderSnapshot?: GroupReportRenderSnapshot
templateId?: SelectableReportTemplateId
}
export interface SaveGeneratedReportRequest {
@@ -50,6 +66,8 @@ export interface SaveGeneratedReportRequest {
htmlPath?: string
pngPath?: string
duration?: number
textModelName?: string
imageModelName?: string
modelName?: string
tokenUsage?: {
input?: number
@@ -63,6 +81,27 @@ export interface SaveGeneratedReportRequest {
endedAt: string
duration: number
}[]
reportSnapshot?: GroupDailyReport
reportMetadata?: GroupReportMetadata
templateId?: SelectableReportTemplateId
}
export interface UpdateGeneratedReportTemplateRequest {
reportId: string
templateId: SelectableReportTemplateId
generatedImage?: string
htmlPath?: string
pngPath?: string
}
export interface PrepareGeneratedReportTemplateSwitchRequest {
reportId: string
}
export interface PrepareGeneratedReportTemplateSwitchResult {
success: boolean
snapshot?: GroupReportRenderSnapshot
error?: string
}
export interface ReportHistoryResult {
@@ -77,6 +116,8 @@ export interface SaveGeneratedReportResult {
error?: string
}
export type UpdateGeneratedReportTemplateResult = SaveGeneratedReportResult
export interface DeleteGeneratedReportResult {
success: boolean
deletedId?: string
+125
View File
@@ -0,0 +1,125 @@
export type ReportTemplateId =
| 'mobile-feed'
| 'mobile-magazine'
| 'mobile-dashboard'
| 'desktop-workspace'
| 'desktop-editorial'
export type LegacyReportTemplateId = 'v1' | 'v2'
export type SelectableReportTemplateId = 'v1' | ReportTemplateId
export type ReportTemplateRequestId = ReportTemplateId | LegacyReportTemplateId
export type ReportTemplatePlatform = 'default' | 'mobile' | 'desktop'
export interface ReportTemplateDefinition {
id: SelectableReportTemplateId
order: number
platform: ReportTemplatePlatform
label: string
name: string
tagline: string
fileLabel: string
cssClass: string
resourceFile: string
captureWidth: number
maxCaptureWidth: number
}
const SHARED_RESOURCE = 'daily_report_templates.html'
export const DEFAULT_REPORT_TEMPLATE: Readonly<ReportTemplateDefinition> = {
id: 'v1',
order: 0,
platform: 'default',
label: '默认模板',
name: '经典日报',
tagline: '熟悉的单栏日报版式,信息完整,适合直接生成与分享',
fileLabel: '经典版',
cssClass: 'template-classic',
resourceFile: 'mobile_daily_report_v1.html',
captureWidth: 430,
maxCaptureWidth: 1200
}
export const REPORT_TEMPLATES: readonly ReportTemplateDefinition[] = [
{
id: 'mobile-feed',
order: 1,
platform: 'mobile',
label: 'Mobile 01',
name: '微信信息流',
tagline: '聊天头像、真实消息与 AI 重点整理并列呈现',
fileLabel: '手机微信信息流',
cssClass: 'template-mobile-feed',
resourceFile: SHARED_RESOURCE,
captureWidth: 430,
maxCaptureWidth: 430
},
{
id: 'mobile-magazine',
order: 2,
platform: 'mobile',
label: 'Mobile 02',
name: 'AI Magazine',
tagline: '大标题、大数字与编辑式留白的科技日报',
fileLabel: '手机AI杂志',
cssClass: 'template-mobile-magazine',
resourceFile: SHARED_RESOURCE,
captureWidth: 430,
maxCaptureWidth: 430
},
{
id: 'mobile-dashboard',
order: 3,
platform: 'mobile',
label: 'Mobile 03',
name: 'AI Command Center',
tagline: 'KPI、热度、排行与问答构成的群聊数据驾驶舱',
fileLabel: '手机数据驾驶舱',
cssClass: 'template-mobile-dashboard',
resourceFile: SHARED_RESOURCE,
captureWidth: 430,
maxCaptureWidth: 430
},
{
id: 'desktop-workspace',
order: 4,
platform: 'desktop',
label: 'Desktop 01',
name: '三栏 AI 工作台',
tagline: '概览、核心讨论、成员与问答同时可见',
fileLabel: '桌面三栏工作台',
cssClass: 'template-desktop-workspace',
resourceFile: SHARED_RESOURCE,
captureWidth: 1440,
maxCaptureWidth: 1920
},
{
id: 'desktop-editorial',
order: 5,
platform: 'desktop',
label: 'Desktop 02',
name: 'Editorial 科技日报',
tagline: '头条、专栏、引语与新闻式分隔构成的编辑版面',
fileLabel: '桌面Editorial',
cssClass: 'template-desktop-editorial',
resourceFile: SHARED_RESOURCE,
captureWidth: 1440,
maxCaptureWidth: 1920
}
]
export const SELECTABLE_REPORT_TEMPLATES: readonly ReportTemplateDefinition[] = [
DEFAULT_REPORT_TEMPLATE,
...REPORT_TEMPLATES
]
export const isReportTemplateId = (value: unknown): value is ReportTemplateId =>
REPORT_TEMPLATES.some((template) => template.id === value)
export const isSelectableReportTemplateId = (
value: unknown
): value is SelectableReportTemplateId =>
SELECTABLE_REPORT_TEMPLATES.some((template) => template.id === value)
export const getReportTemplate = (value?: string): ReportTemplateDefinition =>
SELECTABLE_REPORT_TEMPLATES.find((template) => template.id === value) || DEFAULT_REPORT_TEMPLATE
+28
View File
@@ -0,0 +1,28 @@
export interface WechatShareServiceConfig {
serviceUrl: string
uploadToken: string
}
export interface WechatShareServiceConfigResult {
success: boolean
configured?: boolean
serviceUrl?: string
error?: string
}
export interface PublishWechatShareCardRequest {
pngPath: string
title: string
description: string
expiresInDays?: number
}
export interface PublishWechatShareCardResult {
success: boolean
cardId?: string
shareUrl?: string
viewUrl?: string
qrCodeDataUrl?: string
expiresAt?: string
error?: string
}
@@ -0,0 +1,65 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AIModelPage } from '../../src/renderer/src/features/settings/pages/AIModelPage'
import type { AIRuntimeModelConfig } from '../../src/shared/ai-provider'
const runtime: AIRuntimeModelConfig = {
providerName: 'Not configured',
model: '',
modelName: 'No model selected',
configured: false,
status: 'untested'
}
describe('AI model settings', () => {
const scrollIntoView = vi.fn()
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView
})
window.api = {
listAIProviders: vi.fn().mockResolvedValue({ success: true, providers: [] }),
getAIRuntimeConfig: vi.fn().mockResolvedValue(runtime)
} as typeof window.api
})
it('reveals the new provider editor and labels model identity fields', async () => {
render(<AIModelPage onRuntimeChange={vi.fn()} onNotice={vi.fn()} />)
await act(async () => {
await Promise.resolve()
})
fireEvent.click(screen.getByRole('button', { name: '添加供应商' }))
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
expect(screen.getByLabelText('供应商名称')).toHaveFocus()
expect(screen.getByLabelText('模型名称')).toHaveAttribute('placeholder', '例如:DeepSeek Chat')
expect(screen.getByLabelText('模型 ID')).toHaveAttribute('placeholder', '例如:deepseek-chat')
})
it('does not reload providers when the parent callback identity changes', async () => {
const firstRuntimeChange = vi.fn()
const secondRuntimeChange = vi.fn()
const { rerender } = render(
<AIModelPage onRuntimeChange={firstRuntimeChange} onNotice={vi.fn()} />
)
await act(async () => {
await Promise.resolve()
})
expect(window.api.listAIProviders).toHaveBeenCalledOnce()
expect(window.api.getAIRuntimeConfig).toHaveBeenCalledOnce()
expect(firstRuntimeChange).toHaveBeenCalledWith(runtime)
await act(async () => {
rerender(<AIModelPage onRuntimeChange={secondRuntimeChange} onNotice={vi.fn()} />)
await Promise.resolve()
})
expect(window.api.listAIProviders).toHaveBeenCalledOnce()
expect(window.api.getAIRuntimeConfig).toHaveBeenCalledOnce()
expect(secondRuntimeChange).not.toHaveBeenCalled()
})
})
+477 -1
View File
@@ -1,8 +1,31 @@
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel'
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
import type { Contact } from '../../src/shared/types'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
const noImageInsights = {
total: 0,
succeeded: 0,
failed: 0,
items: [],
failures: []
}
const currentModel = {
providerId: 'provider-1',
providerName: '默认服务',
model: 'model-1',
modelName: '默认模型',
configured: true,
status: 'connected' as const
}
const groupContact: Contact = {
md5: 'group-md5',
@@ -18,6 +41,7 @@ describe('daily report controls', () => {
value: {
getAppLogPath: vi.fn(async () => ''),
revealAppLog: vi.fn(async () => undefined),
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
getGroupSnapshot: vi.fn(async () => ({
members: [
{
@@ -50,7 +74,13 @@ describe('daily report controls', () => {
error=""
voiceTranscriptionProgress={progress}
voiceTranscriptionEnabled
preparationProgress={null}
imageInsightSummary={noImageInsights}
canRetryModelStep={false}
currentModel={currentModel}
onRetry={vi.fn()}
onContinueAfterImageFailures={vi.fn()}
onCancelAfterImageFailures={vi.fn()}
/>
)
@@ -63,13 +93,435 @@ describe('daily report controls', () => {
error=""
voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false}
preparationProgress={null}
imageInsightSummary={noImageInsights}
canRetryModelStep={false}
currentModel={currentModel}
onRetry={vi.fn()}
onContinueAfterImageFailures={vi.fn()}
onCancelAfterImageFailures={vi.fn()}
/>
)
expect(screen.queryByText('转写语音消息')).not.toBeInTheDocument()
expect(screen.getByText('2/4')).toBeVisible()
})
it('shows image insight results and pauses for confirmation when some images fail', () => {
const onContinue = vi.fn()
const onCancel = vi.fn()
render(
<ReportTaskStatusPanel
phase="awaitingImageDecision"
error=""
voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false}
preparationProgress={{
stage: 'summarizingInput',
label: '等待确认是否继续文字总结',
completed: 2,
total: 3
}}
imageInsightSummary={{
total: 3,
succeeded: 2,
failed: 1,
items: [
{
messageId: 'image-1',
sender: '成员一',
time: '10:20',
description: '一张表格型网页截图。',
ocrText: '列 A 列 B',
tags: ['表格', '网页']
}
],
failures: [
{
messageId: 'image-2',
sender: '成员二',
time: '10:21',
error: 'fetch failed'
}
]
}}
canRetryModelStep={false}
currentModel={currentModel}
onRetry={vi.fn()}
onContinueAfterImageFailures={onContinue}
onCancelAfterImageFailures={onCancel}
/>
)
expect(screen.getByText('等待确认')).toBeVisible()
expect(screen.getByText('有 1 张图片识别失败')).toBeVisible()
expect(screen.getByText('一张表格型网页截图。')).toBeVisible()
expect(screen.getByText('OCR:列 A 列 B')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '继续文字总结' }))
fireEvent.click(screen.getByRole('button', { name: '停止生成' }))
expect(onContinue).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('allows switching models and retrying only the model step', async () => {
const onRetry = vi.fn()
window.api.listAIProviders = vi.fn(async () => ({
success: true,
providers: [
{
id: 'provider-2',
name: '备用服务',
type: 'openai-compatible',
baseUrl: 'https://example.test',
auth: { type: 'bearer' },
models: [
{
id: 'model-2',
name: '备用模型',
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
}
],
defaultModel: 'model-2',
advanced: { timeoutMs: 120000, extraHeaders: {} },
hasApiKey: true,
isDefault: false,
status: 'connected'
}
]
}))
render(
<ReportTaskStatusPanel
phase="error"
error="fetch failed"
voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false}
preparationProgress={null}
imageInsightSummary={noImageInsights}
canRetryModelStep
currentModel={currentModel}
onRetry={onRetry}
onContinueAfterImageFailures={vi.fn()}
onCancelAfterImageFailures={vi.fn()}
/>
)
const retryButton = await screen.findByRole('button', { name: '使用所选模型重新生成' })
expect(screen.getByRole('option', { name: '备用服务 · 备用模型' })).toBeVisible()
fireEvent.click(retryButton)
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({ providerId: 'provider-2', model: 'model-2' })
)
expect(screen.getByText(/从第三步继续/)).toBeVisible()
})
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', () => {
const onTextModelChange = vi.fn()
const onVisionModelChange = vi.fn()
const textModels = [
{
providerId: 'deepseek',
providerName: 'DeepSeek',
model: 'deepseek-chat',
modelName: 'DeepSeek Chat',
configured: true as const,
status: 'connected' as const
},
{
providerId: 'openai',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'GPT-5.6 Sol',
configured: true as const,
status: 'connected' as const
}
]
const visionModels = [
{
providerId: 'sol-provider',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'GPT-5.6 Sol',
configured: true as const,
status: 'connected' as const
}
]
render(
<ModelSummary
config={textModels[0]}
visionConfig={visionModels[0]}
textModels={textModels}
visionModels={visionModels}
onTextModelChange={onTextModelChange}
onVisionModelChange={onVisionModelChange}
onOpenSettings={vi.fn()}
/>
)
const textSelect = screen.getByRole('combobox', { name: '文字总结模型' })
const visionSelect = screen.getByRole('combobox', { name: '图片理解模型' })
expect(textSelect).toHaveValue('deepseek::deepseek-chat')
expect(visionSelect).toHaveValue('sol-provider::gpt-5.6-sol')
expect(screen.getAllByRole('option', { name: 'OpenAI · GPT-5.6 Sol' })).toHaveLength(2)
fireEvent.change(textSelect, { target: { value: 'openai::gpt-5.6-sol' } })
fireEvent.change(visionSelect, { target: { value: 'sol-provider::gpt-5.6-sol' } })
expect(onTextModelChange).toHaveBeenCalledWith(textModels[1])
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[0])
expect(screen.getByText(/图片识别缓存 10 分钟/)).toBeVisible()
})
it('zooms relative to a full-image fit constrained by viewport width and height', () => {
const originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver = class {
observe(): void {
return undefined
}
disconnect(): void {
return undefined
}
unobserve(): void {
return undefined
}
}
const report: GeneratedReportRecord = {
id: 'report-1',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
reportDate: '2026-08-12',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture'
}
render(
<ReportViewer
report={report}
hasReports
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 image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1440 })
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 4000 })
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
configurable: true,
value: 760
})
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
configurable: true,
value: 600
})
fireEvent.load(image)
expect(image.style.width).toBe('200px')
fireEvent.click(screen.getByRole('button', { name: '缩小' }))
expect(image.style.width).toBe('160px')
fireEvent.click(screen.getByRole('button', { name: '放大' }))
expect(image.style.width).toBe('200px')
expect(screen.getByRole('button', { name: '完整显示' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '原始大小' }))
expect(image.style.width).toBe('1440px')
fireEvent.click(screen.getByRole('button', { name: '完整显示' }))
expect(image.style.width).toBe('200px')
globalThis.ResizeObserver = originalResizeObserver
})
it('keeps zoom working when a newly saved report replaces the initial result', () => {
const originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver = class {
observe(): void {
return undefined
}
disconnect(): void {
return undefined
}
unobserve(): void {
return undefined
}
}
const baseReport: GeneratedReportRecord = {
id: 'temporary-result',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-13T10:00:00.000Z',
reportDate: '2026-08-13',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture'
}
const props = {
hasReports: true,
onBackToConfigure: vi.fn(),
onRegenerate: vi.fn(),
onCopyImage: vi.fn(async () => ({ success: true })),
onReveal: vi.fn(async () => ({ success: true })),
onSwitchTemplate: vi.fn(async () => ({ success: true }))
}
const { rerender } = render(<ReportViewer report={baseReport} {...props} />)
let image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
configurable: true,
value: 544
})
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
configurable: true,
value: 1044
})
fireEvent.load(image)
expect(image.style.width).toBe('500px')
rerender(<ReportViewer report={{ ...baseReport, id: 'saved-result' }} {...props} />)
image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
configurable: true,
value: 544
})
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
configurable: true,
value: 1044
})
fireEvent.load(image)
fireEvent.click(screen.getByRole('button', { name: '放大' }))
expect(image.style.width).toBe('625px')
globalThis.ResizeObserver = originalResizeObserver
})
it('keeps secondary report actions inside More and labels both AI model roles', () => {
render(
<>
<ReportToolbar
canCopyImage
canReveal
canShare
canSwitchTemplate
currentTemplateId="v1"
isSwitchingTemplate={false}
onSwitchTemplate={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn()}
onReveal={vi.fn()}
onShare={vi.fn()}
/>
<ReportInfoPanel
report={{
id: 'model-info',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-13T10:00:00.000Z',
reportDate: '2026-08-13',
htmlStatus: 'ready',
pngStatus: 'ready',
textModelName: 'deepseek-chat',
imageModelName: 'gpt-5.6-sol'
}}
onReveal={vi.fn(async () => ({ success: true }))}
/>
</>
)
expect(screen.queryByRole('button', { name: '生成微信卡片' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '更多' }))
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
expect(screen.getByText('文字模型')).toBeVisible()
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
expect(screen.getByText('图片模型')).toBeVisible()
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
})
it('switches templates from the top toolbar using the saved report snapshot', async () => {
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
const report: GeneratedReportRecord = {
id: 'report-switch',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
reportDate: '2026-08-12',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture',
templateId: 'mobile-feed',
reportSnapshot: {} as GeneratedReportRecord['reportSnapshot'],
reportMetadata: {} as GeneratedReportRecord['reportMetadata']
}
const { rerender } = render(
<ReportViewer
report={report}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={onSwitchTemplate}
/>
)
fireEvent.click(screen.getByRole('button', { name: '切换模板' }))
expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible()
expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible()
fireEvent.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard'))
rerender(
<ReportViewer
report={{
...report,
reportSnapshot: undefined,
reportMetadata: undefined,
htmlPath: '/tmp/legacy-report.html'
}}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={onSwitchTemplate}
/>
)
expect(screen.getByRole('button', { name: '切换模板' })).toBeEnabled()
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
'title',
'使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
)
rerender(
<ReportViewer
report={{
...report,
reportSnapshot: undefined,
reportMetadata: undefined,
reportRenderSnapshot: undefined,
htmlPath: undefined
}}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={onSwitchTemplate}
/>
)
expect(screen.getByRole('button', { name: '切换模板' })).toBeDisabled()
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
'title',
'当前报告缺少可复用数据和 HTML,无法切换模板'
)
})
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
render(<ReportGroupMemberSelector sourceContact={groupContact} />)
@@ -78,4 +530,28 @@ describe('daily report controls', () => {
expect(screen.getByText('通讯录备注一')).toBeVisible()
expect(screen.getByText('wxid-one')).toBeVisible()
})
it('offers the classic default plus three mobile and two desktop report templates', () => {
const onChange = vi.fn()
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
expect(screen.getAllByRole('radio')).toHaveLength(6)
expect(screen.getByText('默认模板', { selector: '.report-template-group-title' })).toBeVisible()
expect(screen.getByText('手机端 · 375414 px')).toBeVisible()
expect(screen.getByText('电脑端 · 12801920 px')).toBeVisible()
expect(screen.getByText('经典日报')).toBeVisible()
expect(screen.getByRole('radio', { name: /经典日报/ })).toBeChecked()
expect(screen.getByText('微信信息流')).toBeVisible()
expect(screen.getByText('AI Magazine')).toBeVisible()
expect(screen.getByText('AI Command Center')).toBeVisible()
expect(screen.getByText('三栏 AI 工作台')).toBeVisible()
expect(screen.getByText('Editorial 科技日报')).toBeVisible()
const previewButtons = screen.getAllByRole('button', { name: '查看版式' })
expect(previewButtons).toHaveLength(6)
fireEvent.click(previewButtons[2])
fireEvent.click(screen.getByRole('button', { name: '选择此模板' }))
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
})
})
+22 -1
View File
@@ -273,6 +273,12 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
const textModel = fixture.page.getByRole('combobox', { name: '文字总结模型' })
const visionModel = fixture.page.getByRole('combobox', { name: '图片理解模型' })
await expect(textModel).toHaveValue('fixture-provider::fixture-model')
await expect(visionModel).toHaveValue('fixture-provider::fixture-vision-model')
await expect(textModel.locator('option')).toHaveCount(2)
await expect(visionModel.locator('option')).toHaveCount(1)
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
@@ -281,6 +287,20 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({
timeout: 15_000
})
await expect(fixture.page.getByText('文字模型')).toBeVisible()
await expect(fixture.page.getByText('固定响应模型')).toBeVisible()
await expect(fixture.page.getByText('图片模型')).toBeVisible()
await expect(fixture.page.getByText('固定图片识别模型')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toHaveCount(0)
await fixture.page.getByRole('button', { name: '更多' }).click()
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
await fixture.page.setViewportSize({ width: 1024, height: 760 })
const reportTitle = fixture.page.getByRole('heading', { name: '产品测试群 群聊日报' })
await expect(reportTitle).toBeVisible()
expect((await reportTitle.boundingBox())?.width || 0).toBeGreaterThan(170)
await expect(fixture.page.getByRole('button', { name: '放大' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '放大' }).click()
const exported = await fixture.page.evaluate(async () =>
window.api.exportGroupReport({
@@ -308,7 +328,8 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
await expect(fixture.page.getByText(/从第三步继续/)).toBeVisible()
await fixture.page.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
await expect(
+55 -1
View File
@@ -411,9 +411,44 @@ handle('ai:getRuntimeConfig', () => ({
status: 'connected',
timeoutMs: 5000
}))
handle('ai:getVisionRuntimeConfig', () => ({
providerId: 'fixture-vision-provider',
providerName: '本地图片假服务',
model: 'fixture-vision-model',
modelName: '固定图片识别模型',
configured: true,
status: 'connected',
timeoutMs: 5000,
source: 'vision-capability'
}))
handle('ai:listProviders', () => ({
success: true,
providers: [],
providers: [
{
id: 'fixture-provider',
name: '本地假服务',
type: 'openai-compatible',
baseUrl: 'http://127.0.0.1:1/v1',
auth: { type: 'none' },
models: [
{
id: 'fixture-model',
name: '固定响应模型',
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
},
{
id: 'fixture-vision-model',
name: '固定图片识别模型',
capabilities: { chat: true, vision: true, ocr: true, longContext: true }
}
],
defaultModel: 'fixture-model',
advanced: { timeoutMs: 5000, extraHeaders: {} },
hasApiKey: true,
isDefault: true,
status: 'connected'
}
],
defaultProviderId: 'fixture-provider'
}))
handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
@@ -479,11 +514,30 @@ handle('report:export', () => {
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
})
handle('report:exportSnapshot', () => {
const htmlPath = path.join(userData, 'fixture-report-snapshot.html')
const pngPath = path.join(userData, 'fixture-report-snapshot.png')
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏模板快照日报</h1>', 'utf8')
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
})
handle('report:prepareTemplateSwitch', () => ({
success: true,
snapshot: {
groupName: '固定脱敏群',
reportDate: '2026-08-12',
values: { REPORT_TITLE: '固定脱敏群日报' }
}
}))
handle('report:listGenerated', () => ({ success: true, reports: [] }))
handle('report:saveGenerated', (request) => ({
success: true,
record: { id: 'fixture-report-record', ...request }
}))
handle('report:updateGeneratedTemplate', (request) => ({
success: true,
record: { id: request.reportId, templateId: request.templateId }
}))
handle('report:deleteGenerated', () => ({ success: true }))
handle('report:reveal', () => ({ success: true }))
handle('copy-image', () => ({ success: true }))
+95 -1
View File
@@ -43,6 +43,7 @@ const state = vi.hoisted(() => ({
}
>,
voiceLookups: [] as number[],
voiceBatches: [] as number[][],
videoLookups: [] as {
createTime?: number
byteLength?: number
@@ -137,6 +138,13 @@ vi.mock('../../src/main/voice-service', () => ({
}
: { success: false, error: '本地未找到语音数据' }
}
async resolveVoices(
references: Array<{ localId: number }>
): Promise<Array<{ success: boolean; data?: string; error?: string }>> {
state.voiceBatches.push(references.map((reference) => reference.localId))
return Promise.all(references.map((reference) => this.resolveVoice('', reference.localId)))
}
}
}))
vi.mock('../../src/main/image-decrypt-service', () => ({
@@ -264,6 +272,7 @@ describe('media export flow', () => {
state.groupSnapshotReads = []
state.groupSnapshots = {}
state.voiceLookups = []
state.voiceBatches = []
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
@@ -391,7 +400,10 @@ describe('media export flow', () => {
send: (_channel: string, payload: (typeof progress)[number]) => progress.push(payload)
}
}
const recognize = vi.fn(async () => ({ success: true as const, transcript: '固定转写文本' }))
const recognize = vi.fn(async () => {
expect(state.voiceLookups).toEqual([])
return { success: true as const, transcript: '固定转写文本' }
})
const result = await runExport(
{
@@ -423,6 +435,88 @@ describe('media export flow', () => {
})
})
it('uses compatible transcript results and waits for one coalesced knowledge update per chat', async () => {
const { runExport } = await import('../../src/main/export-service')
state.messages = [
message({
id: 'voice-cache-a',
type: '语音',
sessionId: 'fixture-session',
localId: 1,
createTime: 1_785_549_600,
contentData: { type: 'voice', duration: 1 }
}),
message({
id: 'voice-cache-b',
type: '语音',
sessionId: 'fixture-session',
localId: 2,
createTime: 1_785_549_601,
contentData: { type: 'voice', duration: 1 }
})
]
const recognize = vi.fn(async (reference: { localId: number }) => ({
success: true as const,
transcript: `缓存文字-${reference.localId}`,
cached: true
}))
const publishTranscript = vi.fn().mockResolvedValue(undefined)
const result = await runExport(
{
jobId: 'voice-cache-coalesced-index',
targets: [target()],
format: 'html',
outputName: 'voice-cache-coalesced-index',
kinds: ['voice'],
includeMedia: true,
includeVoiceTranscripts: true
},
{ isDestroyed: () => true, webContents: { send: vi.fn() } } as never,
{ recognize, publishTranscript }
)
expect(result.success, result.error).toBe(true)
expect(recognize).toHaveBeenCalledTimes(2)
expect(recognize).toHaveBeenNthCalledWith(1, expect.objectContaining({ localId: 1 }), {
publishTranscriptUpdate: false
})
expect(publishTranscript).toHaveBeenCalledOnce()
expect(publishTranscript).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'fixture-session', localId: 2 }),
'缓存文字-2',
true
)
expect(state.voiceBatches).toEqual([[1, 2]])
expect(readArchive(result.outputPath!).messages.map((item) => item.voiceTranscript)).toEqual([
'缓存文字-1',
'缓存文字-2'
])
})
it('clears stale missing errors when an incremental merge restores playable voice data', async () => {
const { mergeHtmlArchiveMessages } = await import('../../src/main/export-service')
const previous = message({
id: 'voice-incremental',
type: '语音',
voiceDataUrl: 'voices/existing.wav',
voiceTranscript: '已有转写',
voiceTranscriptError: '旧转写错误'
})
const current = message({
id: 'voice-incremental',
type: '语音',
exportMediaError: '语音文件缺失:获取语音数据失败'
})
const [merged] = mergeHtmlArchiveMessages([previous], [current])
expect(merged.voiceDataUrl).toBe('voices/existing.wav')
expect(merged.voiceTranscript).toBe('已有转写')
expect(merged.exportMediaError).toBeUndefined()
expect(merged.voiceTranscriptError).toBeUndefined()
})
it('uses the customized file name as the HTML archive title', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
@@ -64,6 +64,8 @@ describe('preload IPC contract', () => {
expect(invoke).toHaveBeenLastCalledWith('cache:clear', 'knowledge')
await api.openKnowledgeDirectory()
expect(invoke).toHaveBeenLastCalledWith('cache:openKnowledgeDirectory')
await api.getAIVisionRuntimeConfig()
expect(invoke).toHaveBeenLastCalledWith('ai:getVisionRuntimeConfig')
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
force: true,
@@ -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'
})
})
})
+129 -3
View File
@@ -30,8 +30,15 @@ vi.mock('electron', () => ({
}
}))
import { assessMigration, executeMigration } from '../../src/main/app-data-migration'
import { getUserDataRoots } from '../../src/main/app-data-paths'
import {
assessMigration,
executeMigration,
migrateLegacyVoiceTranscripts,
runFirstLaunchMigration
} from '../../src/main/app-data-migration'
import { getUserDataRoots, type UserDataRoots } from '../../src/main/app-data-paths'
import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository'
import type { TranscriptRecord } from '../../src/main/voice-pipeline/types'
let root = ''
@@ -43,7 +50,7 @@ afterEach(() => {
fs.removeSync(root)
})
function roots() {
function roots(): UserDataRoots {
return getUserDataRoots(path.join(root, 'Application Support'))
}
@@ -72,6 +79,16 @@ describe('TraceMemo app data migration', () => {
})
})
it('recognizes a legacy voice transcript cache as user-owned data', () => {
const fixture = roots()
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'))
expect(assessMigration(fixture)).toMatchObject({
shouldPrompt: true,
reason: 'legacy-assets-detected',
sourceRoot: fixture.legacy
})
})
it('detects legacy settings but never proposes overwriting valid TraceMemo data', () => {
const fixture = roots()
writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}')
@@ -180,4 +197,113 @@ describe('TraceMemo app data migration', () => {
)
expect(fs.readFileSync(path.join(fixture.legacy, 'settings.json'), 'utf8')).toContain('legacy')
})
it('supplements legacy voice transcripts into an existing TraceMemo cache', async () => {
const fixture = roots()
const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite')
const currentPath = path.join(fixture.current, 'cache', 'voice-transcripts.sqlite')
const record: TranscriptRecord = {
accountId: 'account-a',
messageIdentity: 'message-a',
audioHash: 'audio-a',
processorVersion: 'processor-v1',
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a',
transcript: '已经转写过的文字',
durationMs: 800,
createdAt: 1,
updatedAt: 1
}
const legacy = new SqliteTranscriptRepository(legacyPath)
legacy.save(record)
legacy.close()
expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('migrated')
expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('skipped')
const current = new SqliteTranscriptRepository(currentPath)
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('已经转写过的文字')
current.close()
})
it('backfills voice transcripts after the original migration was already completed', async () => {
const fixture = roots()
const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite')
const legacy = new SqliteTranscriptRepository(legacyPath)
legacy.save({
accountId: 'account-a',
messageIdentity: 'message-a',
audioHash: 'audio-a',
processorVersion: 'processor-v1',
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a',
transcript: '补迁文字',
durationMs: 800,
createdAt: 1,
updatedAt: 1
})
legacy.close()
fs.ensureDirSync(fixture.current)
fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), {
version: 1,
status: 'completed',
sourceRoot: fixture.legacy,
updatedAt: '2026-08-11T00:00:00.000Z',
items: { 'settings.json': 'migrated' },
secretFailures: []
})
const result = await runFirstLaunchMigration(fixture)
expect(result.action).toBe('migrated')
expect(result.execution?.state.items['cache/voice-transcripts.sqlite']).toBe('migrated')
expect(result.execution?.state.status).toBe('completed')
const current = new SqliteTranscriptRepository(
path.join(fixture.current, 'cache', 'voice-transcripts.sqlite')
)
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('补迁文字')
current.close()
})
it('does not retry a voice transcript backfill after it was marked failed', async () => {
const fixture = roots()
writeFixture(path.join(fixture.legacy, 'settings.json'), '{}')
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite')
fs.ensureDirSync(fixture.current)
fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), {
version: 1,
status: 'completed',
sourceRoot: fixture.legacy,
updatedAt: '2026-08-11T00:00:00.000Z',
items: { 'cache/voice-transcripts.sqlite': 'failed' },
secretFailures: []
})
const result = await runFirstLaunchMigration(fixture)
expect(result.action).toBe('none')
expect(result.execution).toBeUndefined()
expect(fs.existsSync(path.join(fixture.current, 'cache', 'voice-transcripts.sqlite'))).toBe(
false
)
})
it('treats a failed voice transcript migration as a non-blocking downgrade', async () => {
const fixture = roots()
writeFixture(path.join(fixture.legacy, 'settings.json'), '{}')
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite')
const result = await executeMigration(fixture.legacy, fixture.current, {
decryptLegacySecrets: async () => ({ databaseKeys: {}, failures: [] }),
agentRoots: () => ({
legacy: path.join(root, 'agent-legacy'),
current: path.join(root, 'agent-current')
}),
now: () => new Date('2026-08-11T00:00:00.000Z')
})
expect(result.state.status).toBe('completed')
expect(result.state.items['cache/voice-transcripts.sqlite']).toBe('failed')
})
})
+56 -1
View File
@@ -33,7 +33,7 @@ describe('export media', () => {
expect(html).toContain('aria-expanded="')
expect(html).toContain('setExpandedTimelineYear')
expect(html).toContain('data-kind="media"')
expect(html).toContain('placeholder="搜索发送者消息内容…"')
expect(html).toContain('placeholder="搜索发送者消息内容或媒体文件名(不含后缀)…"')
expect(html).toContain('font-size: 16px;')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
@@ -156,6 +156,61 @@ describe('export media', () => {
dom.window.close()
})
it('matches exported image and video filenames exactly without their extension', () => {
const html = renderExportPage('媒体文件名搜索')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const imageFileName = 'image_0123456789abcdef.jpg'
const videoFileName = 'video_fedcba9876543210.mp4'
const messages: Message[] = [
{
...messageForArchive('image-name', 'fixture', '媒体文件名搜索', '', 1),
type: '图片',
exportMediaType: 'image',
exportMediaUrl: `media/${imageFileName}`,
contentData: { type: 'image' }
},
{
...messageForArchive('video-name', 'fixture', '媒体文件名搜索', '', 2),
type: '视频',
exportMediaType: 'video',
exportMediaUrl: `media/${videoFileName}`,
contentData: { type: 'video' }
}
]
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '媒体文件名搜索',
messages
}
})
dom.window.eval(inlineScriptOf(html))
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = 'IMAGE_0123456789ABCDEF'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelectorAll('img.media-image')).toHaveLength(1)
expect(dom.window.document.querySelectorAll('video.media-image')).toHaveLength(0)
search.value = '0123456789abcdef'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(0)
search.value = 'video_fedcba9876543210'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelectorAll('img.media-image')).toHaveLength(0)
expect(dom.window.document.querySelectorAll('video.media-image')).toHaveLength(1)
search.value = videoFileName
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(0)
dom.window.close()
})
it('filters a v2 merged archive by conversation before search and month counts', () => {
const html = renderExportPage('合并档案')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
+191 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildGroupReportInput,
parseGroupDailyReport
@@ -29,6 +29,16 @@ const media = {
funBadges: []
}
const previousWindow = (globalThis as { window?: unknown }).window
afterEach(() => {
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, 'window')
} else {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow })
}
})
describe('group report parsing', () => {
it('keeps only distinct real participants in the hero avatar list', () => {
expect(
@@ -113,6 +123,124 @@ describe('group report parsing', () => {
expect(input.prompt).toContain('微信系统消息:由于账号安全原因,无法加入当前群聊。')
})
it('injects successful image insights into the model prompt and reports partial failures', async () => {
const messages: Message[] = [
{
id: 'image-1',
from: 'member',
type: '图片',
datetime: '2026-08-12 10:00:00',
content: '[图片]',
name: '成员一',
isSender: false,
sessionId: 'group@chatroom',
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'one.dat' }
},
{
id: 'image-2',
from: 'member',
type: '图片',
datetime: '2026-08-12 10:01:00',
content: '[图片]',
name: '成员二',
isSender: false,
sessionId: 'group@chatroom',
contentData: { type: 'image', md5: 'b'.repeat(32), datName: 'two.dat' }
}
]
const progress = vi.fn()
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
api: {
imageListCandidates: vi.fn(async () => ({
success: true,
candidates: [
{
messageId: 'image-1',
imageHash: 'a'.repeat(32),
md5: 'a'.repeat(32),
datName: 'one.dat',
sessionId: 'group@chatroom',
sender: '成员一',
sentAt: new Date('2026-08-12 10:00:00').getTime(),
heatScore: 10
},
{
messageId: 'image-2',
imageHash: 'b'.repeat(32),
md5: 'b'.repeat(32),
datName: 'two.dat',
sessionId: 'group@chatroom',
sender: '成员二',
sentAt: new Date('2026-08-12 10:01:00').getTime(),
heatScore: 9
}
]
})),
getImage: vi.fn(async () => ({
success: true,
data: 'data:image/png;base64,fixture'
})),
imageAnalyze: vi
.fn()
.mockResolvedValueOnce({
success: true,
insight: {
id: 'insight-1',
messageId: 'image-1',
imageHash: 'a'.repeat(32),
description: '一张表格型网页截图,包含多列数据。',
ocrText: '项目 状态 负责人',
tags: ['表格', '管理界面'],
category: 'screenshot',
importance: 'medium',
provider: 'vision-provider',
model: 'vision-model',
createdAt: Date.now(),
updatedAt: Date.now(),
sender: '成员一',
sentAt: new Date('2026-08-12 10:00:00').getTime(),
sessionId: 'group@chatroom'
}
})
.mockResolvedValueOnce({ success: false, error: 'fetch failed' })
}
}
})
const input = await buildGroupReportInput(messages, null, true, 'full', {
onProgress: progress,
visionModel: {
providerId: 'selected-vision-provider',
providerName: '视觉服务',
model: 'selected-vision-model',
modelName: '视觉模型',
configured: true,
status: 'connected'
}
})
expect(window.api.imageAnalyze).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'selected-vision-provider',
modelId: 'selected-vision-model'
})
)
expect(input.prompt).toContain('AI 图片识别摘要:')
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
expect(input.imageInsightSummary).toMatchObject({ total: 2, succeeded: 1, failed: 1 })
expect(input.imageInsightSummary.failures[0]).toMatchObject({
messageId: 'image-2',
error: 'fetch failed'
})
expect(progress).toHaveBeenCalledWith(
expect.objectContaining({ stage: 'recognizingImages', completed: 2, total: 2 })
)
})
it('falls back to topic keywords when the model omits top-level keywords', () => {
const report = parseGroupDailyReport(
JSON.stringify({
@@ -133,4 +261,66 @@ describe('group report parsing', () => {
expect(report.keywords).toEqual(['肌酸', '训练', '健身安排'])
})
it('does not render the legacy gallery even when old report data contains it', () => {
const report = parseGroupDailyReport(
JSON.stringify({
topics: [{ title: '图片话题', summary: '围绕图片展开讨论。', keywords: ['图片'] }]
}),
[],
'',
[],
metadata,
{
gallery: [
{
sender: '成员一',
time: '10:00',
imageUrl: 'data:image/png;base64,fixture',
note: '旧相册数据'
}
],
voiceHighlights: [],
funBadges: []
}
)
expect(report.media.gallery).toEqual([])
expect(report.sectionMeta?.gallery).toMatchObject({ enabled: false, displayedCount: 0 })
})
it('allows a text report with zero AI images when no image reaches the hot threshold', async () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
api: {
imageListCandidates: vi.fn(async () => ({ success: true, candidates: [] })),
getImage: vi.fn(),
imageAnalyze: vi.fn()
}
}
})
const input = await buildGroupReportInput(
[
{
id: 'cold-image',
from: 'member',
type: '图片',
datetime: '2026-08-12 10:00:00',
content: '[图片]',
name: '成员一',
isSender: false,
sessionId: 'group@chatroom',
contentData: { type: 'image', md5: 'c'.repeat(32), datName: 'cold.dat' }
}
],
null,
true,
'full'
)
expect(input.imageInsightSummary).toMatchObject({ total: 0, succeeded: 0, failed: 0 })
expect(input.media.gallery).toEqual([])
})
})
+297
View File
@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
IMAGE_INSIGHT_CACHE_TTL_MS,
isFreshImageInsight,
type ImageInsight
} from '../../src/shared/image-insight'
const { getByHash, upsert } = vi.hoisted(() => ({
getByHash: vi.fn(),
upsert: vi.fn()
}))
vi.mock('../../src/main/db/image-insights-store', () => ({
imageInsightsStore: {
getByHash,
upsert,
listBySession: vi.fn()
}
}))
import { imageInsightService } from '../../src/main/services/image-insight-service'
const query = {
sessionId: 'group@chatroom',
startTime: 0,
endTime: Date.now(),
limit: 3
}
const input = (
id: string,
responseCount: number,
interactionCount: number
): {
messageId: string
md5: string
sessionId: string
sender: string
sentAt: number
responseCount: number
interactionCount: number
} => ({
messageId: id,
md5: id.repeat(32).slice(0, 32),
sessionId: 'group@chatroom',
sender: id,
sentAt: Date.now(),
responseCount,
interactionCount
})
describe('ImageInsightService hot image selection', () => {
beforeEach(() => {
getByHash.mockReset()
getByHash.mockReturnValue(null)
upsert.mockReset()
})
it('returns fewer than three images when only two pass the hot threshold', async () => {
const result = await imageInsightService.listTopHotImages(query, [
input('a', 3, 0),
input('b', 1, 1),
input('c', 1, 0),
input('d', 0, 5),
input('e', 0, 0)
])
expect(result.map((item) => item.messageId)).toEqual(['a', 'b'])
})
it('keeps the three highest-scoring hot images when more are eligible', async () => {
const result = await imageInsightService.listTopHotImages(query, [
input('a', 2, 0),
input('b', 5, 0),
input('c', 1, 1),
input('d', 3, 1)
])
expect(result.map((item) => item.messageId)).toEqual(['b', 'd', 'a'])
expect(result).toHaveLength(3)
})
it('returns no candidates when no image has meaningful follow-up activity', async () => {
const result = await imageInsightService.listTopHotImages(query, [
input('a', 1, 0),
input('b', 0, 4),
input('c', 0, 0)
])
expect(result).toEqual([])
})
it('keeps a cached insight attached to an eligible candidate', async () => {
const cached = {
imageHash: 'a'.repeat(32),
description: '缓存识别结果',
updatedAt: Date.now()
}
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
})
it('does not attach a cached insight once its 10-minute TTL has elapsed', async () => {
getByHash.mockReturnValue({
imageHash: 'a'.repeat(32),
description: '过期识别结果',
updatedAt: Date.now() - IMAGE_INSIGHT_CACHE_TTL_MS
})
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
expect(result[0]).not.toHaveProperty('insight')
})
})
describe('ImageInsightService cache TTL and vision routing', () => {
const now = new Date('2026-08-12T10:00:00.000Z').getTime()
const cachedInsight = (updatedAt: number): ImageInsight => ({
id: 'cached-insight',
messageId: 'image-1',
imageHash: 'a'.repeat(32),
description: '缓存图片描述',
tags: ['缓存'],
category: 'screenshot',
importance: 'medium',
provider: 'vision-provider',
model: 'vision-model',
createdAt: updatedAt,
updatedAt,
sender: '成员一',
sentAt: now,
sessionId: 'group@chatroom'
})
beforeEach(() => {
vi.useRealTimers()
getByHash.mockReset()
getByHash.mockReturnValue(null)
upsert.mockReset()
})
it('treats only results strictly newer than 10 minutes as fresh', () => {
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1), now)).toBe(true)
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS), now)).toBe(false)
expect(isFreshImageInsight(cachedInsight(0), now)).toBe(false)
})
it('uses the independent vision runtime after an expired cache entry', async () => {
vi.useFakeTimers()
vi.setSystemTime(now)
getByHash.mockReturnValue(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS))
const analyzeImage = vi.fn(async () => ({
success: true,
data: JSON.stringify({
description: '重新识别后的图片描述',
ocrText: '新的 OCR',
tags: ['更新', '截图'],
category: 'screenshot',
importance: 'high'
})
}))
const getVisionRuntimeConfig = vi.fn(() => ({
providerId: 'sol-provider',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'gpt-5.6-sol',
configured: true
}))
imageInsightService.bind({
providerService: {
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
getVisionRuntimeConfig,
analyzeImage
},
decryptService: {
findImageFile: () => null,
decryptImageToBase64: () => null
}
})
const result = await imageInsightService.analyze({
imageHash: 'a'.repeat(32),
imageDataUrl: 'data:image/png;base64,fixture',
messageId: 'image-1',
sender: '成员一',
sentAt: now,
sessionId: 'group@chatroom'
})
expect(result).toMatchObject({ success: true, fromCache: false })
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
providerId: 'sol-provider',
modelId: 'gpt-5.6-sol'
})
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
description: '重新识别后的图片描述',
provider: 'sol-provider',
model: 'gpt-5.6-sol',
updatedAt: now
})
)
})
it('uses the report-selected vision model on a cache miss', async () => {
vi.useFakeTimers()
vi.setSystemTime(now)
const analyzeImage = vi.fn(async () => ({
success: true,
data: JSON.stringify({
description: '指定模型识别结果',
tags: ['指定'],
category: 'screenshot',
importance: 'medium'
})
}))
const getVisionRuntimeConfig = vi.fn(() => ({
providerId: 'automatic-provider',
providerName: '自动模型',
model: 'automatic-vision',
modelName: '自动视觉模型',
configured: true
}))
imageInsightService.bind({
providerService: {
list: () => ({ providers: [], defaultProviderId: 'automatic-provider' }),
getVisionRuntimeConfig,
analyzeImage
},
decryptService: {
findImageFile: () => null,
decryptImageToBase64: () => null
}
})
const result = await imageInsightService.analyze({
imageHash: 'b'.repeat(32),
imageDataUrl: 'data:image/png;base64,fixture',
messageId: 'image-selected',
sender: '成员二',
sentAt: now,
sessionId: 'group@chatroom',
providerId: 'selected-provider',
modelId: 'selected-vision'
})
expect(result).toMatchObject({ success: true, fromCache: false })
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
providerId: 'selected-provider',
modelId: 'selected-vision'
})
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'selected-provider', model: 'selected-vision' })
)
})
it('returns a fresh cached result without calling the vision model', async () => {
vi.useFakeTimers()
vi.setSystemTime(now)
const cached = cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1)
getByHash.mockReturnValue(cached)
const analyzeImage = vi.fn()
imageInsightService.bind({
providerService: {
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
getVisionRuntimeConfig: () => ({
providerId: 'sol-provider',
providerName: 'OpenAI',
model: 'gpt-5.6-sol',
modelName: 'gpt-5.6-sol',
configured: true
}),
analyzeImage
},
decryptService: {
findImageFile: () => null,
decryptImageToBase64: () => null
}
})
const result = await imageInsightService.analyze({
imageHash: cached.imageHash,
imageDataUrl: 'data:image/png;base64,fixture',
messageId: cached.messageId,
sender: cached.sender,
sentAt: cached.sentAt,
sessionId: cached.sessionId
})
expect(result).toEqual({ success: true, insight: cached, fromCache: true })
expect(analyzeImage).not.toHaveBeenCalled()
expect(upsert).not.toHaveBeenCalled()
})
})
+187
View File
@@ -0,0 +1,187 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { GroupDailyReport, GroupReportMetadata } from '../../src/shared/group-report'
const root = mkdtempSync(join(tmpdir(), 'tracememo-report-history-'))
vi.mock('electron', () => ({
app: { getPath: () => root },
nativeImage: {
createFromPath: () => ({
isEmpty: () => false,
getSize: () => ({ width: 430, height: 1200 })
})
}
}))
const reportSnapshot = {
overview: '已生成的结构化日报',
topics: [],
resources: [],
importantMessages: [],
quotes: [],
qa: [],
todos: [],
unresolved: [],
storylines: [],
reversals: [],
participantChains: [],
analytics: {
topicHeat: [],
activeTimeline: '',
topSpeakers: [],
voiceLeaderboard: []
},
keywords: [],
media: { gallery: [], visionGallery: [], voiceHighlights: [], funBadges: [] }
} satisfies GroupDailyReport
const reportMetadata = {
groupName: '测试群',
reportDate: '2026-08-12',
dateRange: '今天',
messageCount: 10,
activeUsers: 3,
timeSpan: '09:0018:00',
generatedAt: '2026-08-12T10:00:00.000Z',
recordNote: '',
footerNote: '',
heroParticipants: [],
avatars: {}
} satisfies GroupReportMetadata
describe('generated report template history', () => {
afterAll(() => {
rmSync(root, { recursive: true, force: true })
})
it('replaces the current report assets while preserving its structured snapshot and id', async () => {
const originalHtml = join(root, 'original.html')
const switchedHtml = join(root, 'switched.html')
writeFileSync(originalHtml, '<h1>Mobile 01</h1>')
writeFileSync(switchedHtml, '<h1>Mobile 03</h1>')
const { saveGeneratedReport, updateGeneratedReportTemplate } =
await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('mobile-01').toString('base64')}`,
htmlPath: originalHtml,
reportSnapshot,
reportMetadata,
templateId: 'mobile-feed'
})
expect(saved.success).toBe(true)
expect(saved.record).toBeDefined()
const updated = await updateGeneratedReportTemplate({
reportId: saved.record!.id,
templateId: 'mobile-dashboard',
generatedImage: `data:image/png;base64,${Buffer.from('mobile-03').toString('base64')}`,
htmlPath: switchedHtml
})
expect(updated.success).toBe(true)
expect(updated.record).toMatchObject({
id: saved.record!.id,
templateId: 'mobile-dashboard',
reportSnapshot,
reportMetadata
})
expect(readFileSync(updated.record!.htmlPath!, 'utf8')).toContain('Mobile 03')
expect(readFileSync(updated.record!.pngPath!).toString()).toBe('mobile-03')
expect(JSON.parse(readFileSync(updated.record!.jsonPath!, 'utf8'))).toMatchObject({
id: saved.record!.id,
templateId: 'mobile-dashboard',
reportSnapshot,
reportMetadata
})
})
it('keeps legacy records viewable but rejects a lossless template switch', async () => {
const legacyHtml = join(root, 'legacy.html')
writeFileSync(legacyHtml, '<h1>Legacy</h1>')
const { saveGeneratedReport, updateGeneratedReportTemplate } =
await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'legacy-group',
contactName: '旧报告',
dateRange: '今天',
messageCount: 5,
generatedAt: '2026-08-12T11:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('legacy').toString('base64')}`,
htmlPath: legacyHtml
})
const updated = await updateGeneratedReportTemplate({
reportId: saved.record!.id,
templateId: 'desktop-editorial',
generatedImage: `data:image/png;base64,${Buffer.from('new').toString('base64')}`,
htmlPath: legacyHtml
})
expect(updated).toEqual({
success: false,
error: '旧报告未保存结构化数据,无法无损切换模板'
})
})
it('extracts and persists a render snapshot once for legacy template switching', async () => {
const legacyHtml = join(root, 'legacy-with-html.html')
writeFileSync(legacyHtml, '<h1>Legacy source</h1>')
const { saveGeneratedReport, prepareGeneratedReportTemplateSwitch } =
await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'legacy-snapshot-group',
contactName: '旧报告快照',
dateRange: '今天',
messageCount: 8,
generatedAt: '2026-08-12T12:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('legacy-snapshot').toString('base64')}`,
htmlPath: legacyHtml
})
const extractSnapshot = vi.fn(async () => ({
groupName: '旧报告快照',
reportDate: '2026-08-12',
values: { REPORT_TITLE: '旧报告快照日报', TOPIC_CARDS: '<div>已有主题</div>' }
}))
const first = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
const second = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
expect(first).toEqual(second)
expect(extractSnapshot).toHaveBeenCalledTimes(1)
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8'))).toMatchObject({
id: saved.record!.id,
reportRenderSnapshot: first.snapshot
})
})
it('persists the classic v1 template as a selectable history template', async () => {
const classicHtml = join(root, 'classic.html')
writeFileSync(classicHtml, '<h1>经典日报</h1>')
const { saveGeneratedReport } = await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'classic-group',
contactName: '经典日报群',
dateRange: '今天',
messageCount: 6,
generatedAt: '2026-08-12T13:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('classic').toString('base64')}`,
htmlPath: classicHtml,
reportSnapshot,
reportMetadata,
templateId: 'v1'
})
expect(saved.success).toBe(true)
expect(saved.record?.templateId).toBe('v1')
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8')).templateId).toBe('v1')
})
})
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from 'vitest'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
import { switchGeneratedReportTemplate } from '../../src/renderer/src/utils/report-template-switch'
const structuredReport = {
id: 'report-1',
contactId: 'group-1',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
reportDate: '2026-08-12',
htmlStatus: 'ready',
pngStatus: 'ready',
templateId: 'mobile-feed',
reportSnapshot: { overview: '已有内容' },
reportMetadata: { groupName: '测试群' }
} as GeneratedReportRecord
describe('report template switching pipeline', () => {
it('only exports the saved snapshot and updates the same history record', async () => {
const api = {
exportGroupReport: vi.fn(async () => ({
success: true,
imageDataUrl: 'data:image/png;base64,new',
htmlPath: '/tmp/new.html',
pngPath: '/tmp/new.png'
})),
exportGroupReportSnapshot: vi.fn(),
prepareGeneratedReportTemplateSwitch: vi.fn(),
updateGeneratedReportTemplate: vi.fn(async () => ({
success: true,
record: { ...structuredReport, templateId: 'mobile-dashboard' as const }
}))
}
const result = await switchGeneratedReportTemplate(structuredReport, 'mobile-dashboard', api)
expect(result.success).toBe(true)
expect(api.exportGroupReport).toHaveBeenCalledTimes(1)
expect(api.exportGroupReport).toHaveBeenCalledWith({
report: structuredReport.reportSnapshot,
metadata: structuredReport.reportMetadata,
templateId: 'mobile-dashboard'
})
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith({
reportId: structuredReport.id,
templateId: 'mobile-dashboard',
generatedImage: 'data:image/png;base64,new',
htmlPath: '/tmp/new.html',
pngPath: '/tmp/new.png'
})
})
it('switches an existing report back to the classic default without rerunning AI', async () => {
const api = {
exportGroupReport: vi.fn(async () => ({
success: true,
imageDataUrl: 'data:image/png;base64,classic',
htmlPath: '/tmp/classic.html',
pngPath: '/tmp/classic.png'
})),
exportGroupReportSnapshot: vi.fn(),
prepareGeneratedReportTemplateSwitch: vi.fn(),
updateGeneratedReportTemplate: vi.fn(async () => ({
success: true,
record: { ...structuredReport, templateId: 'v1' as const }
}))
}
const result = await switchGeneratedReportTemplate(structuredReport, 'v1', api)
expect(result.success).toBe(true)
expect(api.exportGroupReport).toHaveBeenCalledWith({
report: structuredReport.reportSnapshot,
metadata: structuredReport.reportMetadata,
templateId: 'v1'
})
expect(api.prepareGeneratedReportTemplateSwitch).not.toHaveBeenCalled()
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith(
expect.objectContaining({ reportId: structuredReport.id, templateId: 'v1' })
)
})
it('migrates a legacy record from its saved HTML before switching', async () => {
const api = {
exportGroupReport: vi.fn(),
exportGroupReportSnapshot: vi.fn(async () => ({
success: true,
imageDataUrl: 'data:image/png;base64,legacy-new',
htmlPath: '/tmp/legacy-new.html',
pngPath: '/tmp/legacy-new.png'
})),
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
success: true,
snapshot: {
groupName: '测试群',
reportDate: '2026-08-12',
values: { REPORT_TITLE: '测试群日报' }
}
})),
updateGeneratedReportTemplate: vi.fn(async () => ({ success: true }))
}
const result = await switchGeneratedReportTemplate(
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
'desktop-editorial',
api
)
expect(result.success).toBe(true)
expect(api.exportGroupReport).not.toHaveBeenCalled()
expect(api.prepareGeneratedReportTemplateSwitch).toHaveBeenCalledWith('report-1')
expect(api.exportGroupReportSnapshot).toHaveBeenCalledWith({
snapshot: expect.objectContaining({ groupName: '测试群' }),
templateId: 'desktop-editorial'
})
})
it('does not call any pipeline step when a legacy record cannot be migrated', async () => {
const api = {
exportGroupReport: vi.fn(),
exportGroupReportSnapshot: vi.fn(),
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
success: false,
error: '当前日报缺少 HTML 文件,无法迁移旧模板数据'
})),
updateGeneratedReportTemplate: vi.fn()
}
const result = await switchGeneratedReportTemplate(
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
'desktop-editorial',
api
)
expect(result).toEqual({ success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' })
expect(api.exportGroupReport).not.toHaveBeenCalled()
expect(api.updateGeneratedReportTemplate).not.toHaveBeenCalled()
})
})
+70
View File
@@ -0,0 +1,70 @@
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_REPORT_TEMPLATE,
getReportTemplate,
isReportTemplateId,
isSelectableReportTemplateId,
REPORT_TEMPLATES,
SELECTABLE_REPORT_TEMPLATES
} from '../../src/shared/report-templates'
describe('daily report templates', () => {
it('exposes the classic default plus exactly three mobile and two desktop product templates', () => {
expect(REPORT_TEMPLATES).toHaveLength(5)
expect(SELECTABLE_REPORT_TEMPLATES).toHaveLength(6)
expect(SELECTABLE_REPORT_TEMPLATES[0]).toEqual(DEFAULT_REPORT_TEMPLATE)
expect(DEFAULT_REPORT_TEMPLATE).toMatchObject({
id: 'v1',
label: '默认模板',
name: '经典日报',
resourceFile: 'mobile_daily_report_v1.html'
})
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')).toHaveLength(3)
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')).toHaveLength(2)
expect(new Set(REPORT_TEMPLATES.map((template) => template.cssClass)).size).toBe(5)
})
it('uses mobile and desktop capture widths appropriate to their layouts', () => {
expect(DEFAULT_REPORT_TEMPLATE.captureWidth).toBe(430)
expect(DEFAULT_REPORT_TEMPLATE.maxCaptureWidth).toBeGreaterThanOrEqual(
DEFAULT_REPORT_TEMPLATE.captureWidth
)
for (const template of REPORT_TEMPLATES) {
expect(
template.platform === 'mobile' ? template.captureWidth : template.captureWidth >= 1280
).toBeTruthy()
expect(template.maxCaptureWidth).toBeGreaterThanOrEqual(template.captureWidth)
}
})
it('resolves v1 and unknown ids to the classic default template', () => {
expect(isReportTemplateId('desktop-editorial')).toBe(true)
expect(isReportTemplateId('v1')).toBe(false)
expect(isSelectableReportTemplateId('v1')).toBe(true)
expect(isSelectableReportTemplateId('v2')).toBe(false)
expect(getReportTemplate('unknown').id).toBe('v1')
expect(getReportTemplate('v1')).toEqual(DEFAULT_REPORT_TEMPLATE)
})
it('ships the classic resource and shared semantic resource with five distinct layout classes', () => {
expect(existsSync(resolve('resources', DEFAULT_REPORT_TEMPLATE.resourceFile))).toBe(true)
const resourcePath = resolve('resources', 'daily_report_templates.html')
expect(existsSync(resourcePath)).toBe(true)
const html = readFileSync(resourcePath, 'utf8')
for (const template of REPORT_TEMPLATES) {
expect(html).toContain(`.${template.cssClass}`)
}
expect(html).toContain('{{TOPIC_CARDS}}')
expect(html).toContain('{{IMPORTANT_MESSAGES}}')
expect(html).toContain('{{QA_CARDS}}')
expect(html).toContain('{{RANK_ITEMS}}')
expect(html).toContain('{{HERO_AVATARS}}')
expect(html).not.toContain('群聊相册')
expect(html).not.toContain('今日群相册')
expect(html).toContain('grid-template-columns: minmax(78px, 104px) minmax(72px, 1fr) 34px')
expect(html).toContain('.template-mobile-dashboard .heat-name {')
expect(html).toContain('white-space: normal')
})
})
+163 -4
View File
@@ -3,6 +3,8 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { PcmAudioProcessor } from '../../src/main/voice-pipeline/audio-processor'
import { VoicePipeline } from '../../src/main/voice-pipeline/voice-pipeline'
import { voiceMessageIdentity } from '../../src/main/voice-pipeline/voice-message-identity'
import { VoiceTaskScheduler } from '../../src/main/voice-pipeline/task-scheduler'
import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository'
import type { TranscriptRecord } from '../../src/main/voice-pipeline/types'
@@ -114,9 +116,13 @@ describe('voice task scheduling', () => {
releaseFirst = resolve
})
)
const background = scheduler.schedule('background', async () => {
order.push('background')
}, { priority: 'background' })
const background = scheduler.schedule(
'background',
async () => {
order.push('background')
},
{ priority: 'background' }
)
const interactive = scheduler.schedule('interactive', async () => {
order.push('interactive')
})
@@ -134,7 +140,9 @@ describe('voice task scheduling', () => {
'background',
async (signal) => {
order.push('background:start')
await new Promise<void>((resolve) => signal.addEventListener('abort', resolve, { once: true }))
await new Promise<void>((resolve) =>
signal.addEventListener('abort', resolve, { once: true })
)
order.push('background:aborted')
throw new DOMException('Recognition cancelled', 'AbortError')
},
@@ -201,4 +209,155 @@ describe('transcript repository', () => {
})
repository.close()
})
it('merges legacy transcripts idempotently without overwriting current records', () => {
const legacyPath = join(root, 'legacy-transcripts.sqlite')
const currentPath = join(root, 'current-transcripts.sqlite')
const legacy = new SqliteTranscriptRepository(legacyPath)
const current = new SqliteTranscriptRepository(currentPath)
const base: TranscriptRecord = {
accountId: 'account-a',
messageIdentity: 'message-1',
audioHash: 'audio-1',
processorVersion: 'processor-v1',
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a',
transcript: '旧缓存文字',
durationMs: 1200,
createdAt: 1,
updatedAt: 1
}
legacy.save(base)
legacy.save({ ...base, messageIdentity: 'message-2', audioHash: 'audio-2' })
current.save({ ...base, transcript: '当前缓存文字', updatedAt: 2 })
legacy.close()
expect(current.mergeFrom(legacyPath)).toBe(1)
expect(current.mergeFrom(legacyPath)).toBe(0)
expect(
current.find({
accountId: base.accountId,
messageIdentity: base.messageIdentity,
audioHash: base.audioHash,
processorVersion: base.processorVersion,
recognizerId: base.recognizerId,
modelVersion: base.modelVersion,
modelFingerprint: base.modelFingerprint
})?.transcript
).toBe('当前缓存文字')
expect(current.findLatest('account-a', 'message-2')?.transcript).toBe('旧缓存文字')
current.close()
})
})
describe('voice pipeline cache lookup', () => {
it('returns a compatible message cache before reading or decoding audio', async () => {
const repository = new SqliteTranscriptRepository(join(root, 'fast-cache.sqlite'))
const reference = { sessionId: 'session', localId: 1, createTime: 2 }
repository.save({
accountId: 'account-a',
messageIdentity: voiceMessageIdentity(reference),
audioHash: 'audio-1',
processorVersion: 'processor-v1',
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a',
transcript: '快速命中',
durationMs: 900,
createdAt: 1,
updatedAt: 1
})
const resolve = vi.fn()
const decode = vi.fn()
const process = vi.fn()
const recognize = vi.fn()
const pipeline = new VoicePipeline(
{ resolve },
{ decode } as never,
{ version: 'processor-v1', process },
{
metadata: {
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a'
},
recognize,
dispose: vi.fn()
},
repository
)
await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({
transcript: '快速命中',
cached: true
})
expect(resolve).not.toHaveBeenCalled()
expect(decode).not.toHaveBeenCalled()
expect(process).not.toHaveBeenCalled()
expect(recognize).not.toHaveBeenCalled()
repository.close()
})
it('falls through when the cached processor version is incompatible', async () => {
const repository = new SqliteTranscriptRepository(join(root, 'version-cache.sqlite'))
const reference = { sessionId: 'session', localId: 1, createTime: 2 }
repository.save({
accountId: 'account-a',
messageIdentity: voiceMessageIdentity(reference),
audioHash: 'old-audio',
processorVersion: 'processor-v0',
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a',
transcript: '不兼容缓存',
durationMs: 900,
createdAt: 1,
updatedAt: 1
})
const resolve = vi.fn().mockResolvedValue({
data: Buffer.from('encoded'),
codec: 'silk',
sourceHash: 'new-audio'
})
const pipeline = new VoicePipeline(
{ resolve },
{
decode: vi.fn().mockResolvedValue({
pcm: Buffer.from([1, 0]),
sampleRate: 16000,
channels: 1,
sourceHash: 'new-audio'
})
} as never,
{
version: 'processor-v1',
process: vi.fn().mockReturnValue({
samples: new Float32Array([0.1]),
sampleRate: 16000,
channels: 1,
sourceHash: 'new-audio',
processorVersion: 'processor-v1',
durationMs: 1
})
},
{
metadata: {
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a'
},
recognize: vi.fn().mockResolvedValue({ text: '新转写' }),
dispose: vi.fn()
},
repository
)
await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({
transcript: '新转写',
cached: false
})
expect(resolve).toHaveBeenCalledOnce()
repository.close()
})
})
+23 -1
View File
@@ -72,7 +72,9 @@ describe('VoiceRecognitionUseCase transcript updates', () => {
it('does not publish a transcript after the account generation changes mid-recognition', async () => {
const useCase = createUseCase()
let finish: ((value: { transcript: string; durationMs: number; cached: boolean }) => void) | undefined
let finish:
| ((value: { transcript: string; durationMs: number; cached: boolean }) => void)
| undefined
const state = useCase as unknown as {
accountGeneration: number
pipeline: { run: ReturnType<typeof vi.fn> }
@@ -98,4 +100,24 @@ describe('VoiceRecognitionUseCase transcript updates', () => {
expect(listener).not.toHaveBeenCalled()
await useCase.dispose()
})
it('publishes an explicit cached transcript for a coalesced export index refresh', async () => {
const useCase = createUseCase()
const listener = vi.fn().mockResolvedValue(undefined)
useCase.onTranscriptUpdate(listener)
const reference = { sessionId: 'fixture-contact', localId: 11, createTime: 1_785_895_202 }
await useCase.publishTranscript(reference, '缓存导出文字', true)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
accountIdentity: 'account-a',
reference,
state: 'transcribed',
transcript: '缓存导出文字',
cached: true
})
)
await useCase.dispose()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest'
import { VoiceService } from '../../src/main/voice-service'
describe('VoiceService batch lookup', () => {
it('retries failed batch entries with the compatible single-item lookup', async () => {
const getVoiceDataBatch = vi.fn().mockResolvedValue([
{ success: false, error: '获取语音数据失败' },
{ success: false, error: '获取语音数据失败' }
])
const service = new VoiceService({ getVoiceDataBatch } as never)
const resolveVoice = vi
.spyOn(service, 'resolveVoice')
.mockImplementation(async (_sessionId, localId) => ({
success: true,
data: `voice-${localId}`
}))
const result = await service.resolveVoices([
{ sessionId: 'session', localId: 10, createTime: 100, svrId: '1000' },
{ sessionId: 'session', localId: 11, createTime: 101, svrId: '1001' }
])
expect(result).toEqual([
{ success: true, data: 'voice-10' },
{ success: true, data: 'voice-11' }
])
expect(resolveVoice).toHaveBeenCalledTimes(2)
expect(resolveVoice).toHaveBeenNthCalledWith(1, 'session', 10, 100, '1000')
expect(resolveVoice).toHaveBeenNthCalledWith(2, 'session', 11, 101, '1001')
})
})
+55
View File
@@ -37,4 +37,59 @@ describe('Wcdb4Client shutdown', () => {
expect(shutdown).toHaveBeenCalledOnce()
}
})
it('restores async batch voice results to request order', async () => {
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
setPrivate(client, 'wcdbGetVoiceDataBatch', vi.fn())
setPrivate(
client,
'callJsonAsync',
vi.fn().mockResolvedValue([
{ index: 1, success: false, error: 'missing' },
{ index: 0, Success: true, hex: 'aabb' }
])
)
await expect(
client.getVoiceDataBatch([
{ sessionId: 'a', createTime: 1, localId: 10, candidates: ['a'] },
{ sessionId: 'b', createTime: 2, localId: 20, candidates: ['b'] }
])
).resolves.toEqual([
{ success: true, hex: 'aabb', error: '' },
{ success: false, hex: undefined, error: 'missing' }
])
})
it('uses the async Koffi path for a single voice lookup and releases the result', async () => {
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
const nativePointer = { value: 'aabb' }
const freeString = vi.fn()
const nativeFunction = {
async: vi.fn((...args: unknown[]) => {
const outHex = args.at(-2) as [unknown]
const callback = args.at(-1) as (error: unknown, code: number) => void
outHex[0] = nativePointer
queueMicrotask(() => callback(null, 0))
})
}
setPrivate(client, 'wcdbGetVoiceData', nativeFunction)
setPrivate(client, 'wcdbFreeString', freeString)
setPrivate(client, 'handle', 1)
setPrivate(client, 'closing', false)
setPrivate(client, 'nativeCallsInFlight', new Set())
setPrivate(
client,
'decodeHexPtr',
vi.fn(() => 'aabb')
)
await expect(client.getVoiceData('session', 100, ['session'], 10, 20)).resolves.toEqual({
success: true,
hex: 'aabb',
error: ''
})
expect(nativeFunction.async).toHaveBeenCalledOnce()
expect(freeString).toHaveBeenCalledWith(nativePointer)
})
})