mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 20:19:09 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15811c820c | ||
|
|
a52da455c3 | ||
|
|
1fc39678ff | ||
|
|
900e4c504c | ||
|
|
8ca8ad9dee | ||
|
|
aed373db93 | ||
|
|
f120f2079c | ||
|
|
46c9b98d75 | ||
|
|
e3b668314d | ||
|
|
4436d7c8ce | ||
|
|
d439b4b749 |
@@ -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=
|
||||
|
||||
@@ -10,8 +10,12 @@ coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
resources/connectors/wechat-personal/
|
||||
.omc
|
||||
.codex/
|
||||
services/share-card-worker/.wrangler/
|
||||
services/share-card-worker/wrangler.local.jsonc
|
||||
skills-lock.json
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
@@ -19,3 +23,4 @@ AGENTS.md
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
.agents
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 API,Codex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。这和微信机器人是两条不同路径:微信机器人收到消息后在微信中回复;外部 Agent 则主动查询历史。
|
||||
<details>
|
||||
通过 Reader Skill 和本机 Local HTTP API,Codex、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 Silicon(M 系列、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>
|
||||
|
||||
@@ -12,6 +12,8 @@ TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块
|
||||
|
||||
- [建立本地知识库](./user-guide/knowledge.md)
|
||||
- [生成群聊日报和总结](./user-guide/report.md)
|
||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
|
||||
- [语音转文字](./user-guide/voice.md)
|
||||
- [导出聊天档案](./user-guide/export.md)
|
||||
- [防撤回](./user-guide/recall-protection.md)
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
# 实验性功能:自托管微信分享卡片
|
||||
|
||||

|
||||
|
||||
> **实验性功能**
|
||||
> 该能力需要用户自行准备 Cloudflare、域名和微信公众平台测试号,目前不属于开箱即用的稳定功能。Cloudflare、微信 JS-SDK、测试号权限或微信客户端行为变化,都可能导致分享卡片失效。
|
||||
|
||||
## 新手推荐:直接交给 Agent
|
||||
|
||||
如果你不熟悉 Cloudflare、Wrangler 或命令行,不需要手动照着整篇文档操作。把下面这个 Skill 文件夹交给 Codex、Claude Code 或其他能够操作项目终端的编程 Agent:
|
||||
|
||||
```text
|
||||
docs/skill/setup-wechat-share-card/
|
||||
```
|
||||
|
||||
然后对 Agent 说:
|
||||
|
||||
```text
|
||||
请使用 setup-wechat-share-card Skill,帮我部署 TraceMemo 的实验性微信分享卡片服务。尽量自动完成,只在缺少必要信息时一次性问我。
|
||||
```
|
||||
|
||||
Agent 会自动:
|
||||
|
||||
- 检查 Node.js、pnpm 和 Wrangler;
|
||||
- 必要时临时下载 Wrangler;
|
||||
- 打开 Cloudflare 登录并执行 `whoami`;
|
||||
- 自动生成 `UPLOAD_TOKEN`;
|
||||
- 创建或复用私有 R2 Bucket;
|
||||
- 写入 Worker Secret;
|
||||
- 根据你的域名生成本机 Worker 配置;
|
||||
- 部署 Worker并执行健康检查和微信签名检查;
|
||||
- 把上传密钥复制到剪贴板,供你粘贴到 TraceMemo。
|
||||
|
||||
Agent 无法替你创建微信测试号或决定使用哪个域名,因此通常只需要你提供:
|
||||
|
||||
1. 你准备使用的分享域名,例如 `share.example.com`;
|
||||
2. 微信测试号页面中的 AppID;
|
||||
3. 微信测试号页面中的 AppSecret;
|
||||
4. 浏览器弹出 Cloudflare OAuth 页面时完成一次登录授权。
|
||||
|
||||
真实配置保存在被 Git 忽略的本机 `.env` 中,不会写入 `.env.example`。不要把 `.env` 发给别人或提交到仓库。
|
||||
|
||||
TraceMemo 可以把已经生成的群聊日报长图发布为一个临时网页,并在微信中分享成带有标题、描述和缩略图的卡片。
|
||||
|
||||
TraceMemo **不提供公共卡片服务器**。使用该功能前,需要按照本文部署一套属于你自己的卡片服务。日报图片将上传到你自己的 Cloudflare R2,而不是上传到 TraceMemo 作者的服务器。
|
||||
|
||||
## 这个功能解决什么问题
|
||||
|
||||
直接把日报 PNG 发到微信,只会显示为一张普通图片。微信卡片还需要:
|
||||
|
||||
- 一个域名 (未能备案的话 在微信里点击多次 可能会被微信内置窗口提示需要备案);
|
||||
- 卡片标题和描述;
|
||||
- 一张微信可以读取的缩略图;
|
||||
- 微信 JS-SDK 签名;
|
||||
- 一个临时保存日报图片的位置。
|
||||
|
||||
本项目提供的 Cloudflare Worker 负责这些工作。桌面端上传日报后,会得到一个分享链接和二维码。用微信扫码打开链接,再点击右上角菜单分享,即可生成微信卡片。
|
||||
|
||||
## 数据会经过哪里
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[TraceMemo 本机日报 PNG] -->|带 UPLOAD_TOKEN 上传| B[你的 Cloudflare Worker]
|
||||
B --> C[你的私有 R2 Bucket]
|
||||
B -->|AppID + AppSecret| D[微信公众平台接口]
|
||||
D -->|access_token 与 jsapi_ticket| B
|
||||
B --> E[临时分享网页]
|
||||
E --> F[微信 JS-SDK]
|
||||
F --> G[微信好友或群聊卡片]
|
||||
```
|
||||
|
||||
与 TraceMemo 的本地浏览能力不同,启用分享卡片后,当前日报长图、缩略图、卡片标题和描述会离开本机,上传到你控制的 Cloudflare 账号。
|
||||
|
||||
## 你需要准备什么
|
||||
|
||||
| 项目 | 用途 | 从哪里获得 |
|
||||
| ------------------------ | -------------------------------------------- | ---------------------------------------- |
|
||||
| Cloudflare 账号 | 运行 Worker 和保存 R2 图片 | 自行注册 Cloudflare |
|
||||
| 托管在 Cloudflare 的域名 | 提供 HTTPS 分享地址 | 使用自己的域名,例如 `share.example.com` |
|
||||
| R2 Bucket | 临时保存日报和缩略图 | 使用 Wrangler 创建 |
|
||||
| `UPLOAD_TOKEN` | 阻止陌生人调用你的上传接口 | **由你自己随机生成** |
|
||||
| 微信测试号 AppID | 标识调用 JS-SDK 的微信应用 | 微信公众平台接口测试号页面 |
|
||||
| 微信测试号 AppSecret | Worker 获取微信接口凭据 | 微信公众平台接口测试号页面 |
|
||||
| JS 接口安全域名 | 告诉微信哪些网页可以使用该 AppID 调用 JS-SDK | 在微信测试号页面填写你的分享域名 |
|
||||
|
||||
微信公众平台接口测试号入口:
|
||||
|
||||
<https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index>
|
||||
|
||||
微信 JS-SDK 官方文档:
|
||||
|
||||
<https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html>
|
||||
|
||||
## 理解三个重要配置
|
||||
|
||||
### `UPLOAD_TOKEN` 从哪里来
|
||||
|
||||
`UPLOAD_TOKEN` **不是从 Cloudflare 或微信后台领取的**,它是卡片服务部署者自己生成的一段随机密码。
|
||||
|
||||
它用于保护 Worker 的上传接口:TraceMemo 上传日报时,会发送:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <UPLOAD_TOKEN>
|
||||
```
|
||||
|
||||
Worker 只有在密钥完全一致时才接受上传。没有它,任何知道接口地址的人都可能向你的 R2 上传文件并消耗资源。
|
||||
|
||||
在 macOS 或 Linux 中生成一枚 64 位十六进制随机密钥:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
示例输出只用于说明格式,不要直接使用:
|
||||
|
||||
```text
|
||||
8a4d...一共 64 个十六进制字符...72ef
|
||||
```
|
||||
|
||||
生成后,同一个值需要配置到两个地方:
|
||||
|
||||
1. Cloudflare Worker Secret `UPLOAD_TOKEN`;
|
||||
2. TraceMemo“生成微信卡片”弹窗中的“上传密钥”。
|
||||
|
||||
如果两边不一致,卡片服务会返回 HTTP 401 或“未授权”。
|
||||
|
||||
TraceMemo 会使用 Electron `safeStorage` 将服务地址和上传密钥加密保存在本机。不要把密钥提交到 Git,也不要写入 `wrangler.jsonc`。
|
||||
|
||||
### AppID 和 AppSecret 从哪里来
|
||||
|
||||
打开[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index),使用微信扫码登录。
|
||||
|
||||
页面上方会显示:
|
||||
|
||||
- `appID`;
|
||||
- `appsecret`。
|
||||
|
||||
将它们分别保存为 Worker Secret:
|
||||
|
||||
```text
|
||||
WECHAT_APP_ID
|
||||
WECHAT_APP_SECRET
|
||||
```
|
||||
|
||||
它们的作用不同:
|
||||
|
||||
- AppID 用于标识这个微信测试应用;
|
||||
- AppSecret 是高敏感凭据,Worker 用它向微信服务器获取 `access_token`;
|
||||
- Worker 再使用 `access_token` 获取 `jsapi_ticket`;
|
||||
- 最后使用 `jsapi_ticket`、当前网页 URL、时间戳和随机串生成 JS-SDK 签名。
|
||||
|
||||
AppSecret 只能保存在 Worker Secret 中。不要把它填写到 TraceMemo 的“上传密钥”输入框,不要发送给前端,也不要提交到仓库。怀疑泄露时,应立即在微信后台重置并更新 Worker Secret。
|
||||
|
||||
### JS 接口安全域名是干什么的
|
||||
|
||||
JS 接口安全域名是微信对网页来源的白名单。
|
||||
|
||||
假设你的分享服务地址是:
|
||||
|
||||
```text
|
||||
https://share.example.com
|
||||
```
|
||||
|
||||
那么测试号页面中的“JS 接口安全域名”应填写:
|
||||
|
||||
```text
|
||||
share.example.com
|
||||
```
|
||||
|
||||
填写时:
|
||||
|
||||
- 不带 `https://`;
|
||||
- 不带 `/s/xxx` 等路径;
|
||||
- 不要填写 Cloudflare Worker 名称;
|
||||
- 必须与用户实际打开分享页时的域名一致。
|
||||
|
||||
它不是用来解析 DNS 的。域名仍然需要先在 Cloudflare 中正确绑定到 Worker。安全域名的作用是告诉微信:允许这个域名下的网页使用当前 AppID 请求 JS-SDK 能力。
|
||||
|
||||
如果没有配置、填错域名,或签名 URL 与实际页面 URL 不一致,通常会出现 `invalid signature`、`config:fail` 或分享信息没有生效。
|
||||
|
||||
微信可能要求下载一个 TXT 验证文件,并确保它可以通过下面的地址访问:
|
||||
|
||||
```text
|
||||
https://share.example.com/微信提供的文件名.txt
|
||||
```
|
||||
|
||||
项目 Worker 已包含根路径验证文件的实现方式。你需要把自己的文件名和内容加入 `services/share-card-worker/src/index.js` 中的 `WECHAT_DOMAIN_VERIFICATION`,然后重新部署。
|
||||
|
||||
## 自托管部署步骤
|
||||
|
||||
以下命令均在项目根目录执行。
|
||||
|
||||
### 1. 登录 Cloudflare
|
||||
|
||||
项目建议使用本地 Wrangler:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler login
|
||||
pnpm exec wrangler whoami
|
||||
```
|
||||
|
||||
如果本地版本的 OAuth 登录出现 `invalid_scope` 等问题,可临时使用更新版本:
|
||||
|
||||
```bash
|
||||
pnpm dlx wrangler@latest login
|
||||
pnpm dlx wrangler@latest whoami
|
||||
```
|
||||
|
||||
登录注意事项:
|
||||
|
||||
- 让 Wrangler 自动打开浏览器最稳妥;
|
||||
- 不要复用以前生成的 OAuth 链接;
|
||||
- 不要修改链接中的 `state`、`code_challenge` 或回调地址;
|
||||
- 不建议使用无痕窗口或跨浏览器复制链接;
|
||||
- 默认回调使用 `localhost:8976`,端口被占用时先结束旧的 Wrangler 登录进程;
|
||||
- 浏览器提示授权成功后,仍应通过 `whoami` 核对账号。
|
||||
|
||||
### 2. 修改 Worker 配置
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
至少修改下面两个位置:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "share.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://share.example.com",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`routes[].pattern` 是 Worker 自定义域名,`PUBLIC_ORIGIN` 是生成分享链接和校验签名来源时使用的完整 HTTPS 地址,两者必须一致。
|
||||
|
||||
不要直接照抄仓库维护者的域名。请替换为你自己 Cloudflare 账号中的域名或子域名。
|
||||
|
||||
### 3. 创建私有 R2 Bucket
|
||||
|
||||
默认配置使用 Bucket 名称:
|
||||
|
||||
```text
|
||||
wechatexplorer-share-reports
|
||||
```
|
||||
|
||||
创建:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler r2 bucket create wechatexplorer-share-reports \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Worker 中的绑定名称是 `REPORTS`。R2 会保存:
|
||||
|
||||
```text
|
||||
cards/<card-id>/card.json
|
||||
cards/<card-id>/report.png
|
||||
cards/<card-id>/thumbnail.jpg
|
||||
```
|
||||
|
||||
- `card.json`:标题、描述、创建时间和过期时间;
|
||||
- `report.png`:完整日报长图;
|
||||
- `thumbnail.jpg`:微信卡片缩略图。
|
||||
|
||||
请保持 R2 Bucket 私有,不要启用公开 `r2.dev` 开发 URL。图片应统一通过 Worker 的随机卡片 URL 读取。
|
||||
|
||||
### 4. 生成并配置 `UPLOAD_TOKEN`
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
复制生成结果,然后执行:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler secret put UPLOAD_TOKEN \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Wrangler 提示输入时粘贴密钥。终端不会正常显示 Secret 内容。
|
||||
|
||||
### 5. 配置微信 AppID 和 AppSecret
|
||||
|
||||
从[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)复制 AppID:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler secret put WECHAT_APP_ID \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
再复制 AppSecret:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler secret put WECHAT_APP_SECRET \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Secret 不会出现在 `wrangler.jsonc` 中。如果你更换 Cloudflare 账号或重新创建 Worker,需要重新配置全部三个 Secret。
|
||||
|
||||
### 6. 配置微信测试号
|
||||
|
||||
在测试号页面完成:
|
||||
|
||||
1. 使用测试微信关注该测试号;
|
||||
2. 将 `share.example.com` 填入“JS 接口安全域名”;
|
||||
3. 按页面提示完成 TXT 文件域名验证;
|
||||
4. 确认 AppID/AppSecret 与刚才写入 Worker 的值来自同一个测试号。
|
||||
|
||||
测试号只适合开发和验证。正式公众号的接口权限、认证要求和后台菜单可能不同,请以微信公众平台实际规则为准。
|
||||
|
||||
### 7. 部署 Worker
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler deploy \
|
||||
--config services/share-card-worker/wrangler.jsonc
|
||||
```
|
||||
|
||||
Cloudflare Custom Domain 要求域名已经位于同一 Cloudflare 账号中。如果该子域名已经存在 A、AAAA 或 CNAME 记录,绑定可能失败。删除冲突记录,或者换一个未使用的子域名,例如 `share2.example.com`。
|
||||
|
||||
更新 Secret 后,如果线上仍提示旧配置,可再执行一次完整部署。
|
||||
|
||||
## 在 TraceMemo 中配置
|
||||
|
||||
生成一份日报后,点击“生成微信卡片(实验性)”。首次使用需要填写:
|
||||
|
||||
```text
|
||||
服务地址:https://share.example.com
|
||||
上传密钥:你自己通过 openssl rand -hex 32 生成的 UPLOAD_TOKEN
|
||||
```
|
||||
|
||||
这里的“上传密钥”绝对不是微信 AppSecret。
|
||||
|
||||
配置保存后,TraceMemo 会上传当前日报和缩略图,返回二维码。使用已经关注测试号的微信扫码,打开页面后再通过右上角菜单分享。
|
||||
|
||||
## 验证部署
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl -fsS https://share.example.com/health
|
||||
```
|
||||
|
||||
正常结果类似:
|
||||
|
||||
```json
|
||||
{ "ok": true, "service": "wechatexplorer-share-card", "storage": "ready" }
|
||||
```
|
||||
|
||||
### JS-SDK 签名检查
|
||||
|
||||
```bash
|
||||
curl -fsS \
|
||||
'https://share.example.com/api/wx-signature?url=https%3A%2F%2Fshare.example.com%2Fhealth'
|
||||
```
|
||||
|
||||
正常结果应包含:
|
||||
|
||||
```text
|
||||
appId
|
||||
timestamp
|
||||
nonceStr
|
||||
signature
|
||||
```
|
||||
|
||||
响应中不应包含 AppSecret、`access_token` 或 `jsapi_ticket`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### HTTP 401 / 未授权
|
||||
|
||||
TraceMemo 中保存的上传密钥与 Worker 的 `UPLOAD_TOKEN` 不一致。重新生成或重新配置时,必须同步更新两边。
|
||||
|
||||
### “微信 JS-SDK 尚未配置”
|
||||
|
||||
Worker 缺少 `WECHAT_APP_ID` 或 `WECHAT_APP_SECRET`。执行两个 `secret put`,再重新部署。
|
||||
|
||||
### `invalid signature` 或分享信息不生效
|
||||
|
||||
依次检查:
|
||||
|
||||
- `PUBLIC_ORIGIN` 是否与浏览器实际访问的 origin 完全一致;
|
||||
- JS 接口安全域名是否只填写了域名;
|
||||
- AppID/AppSecret 是否属于同一个测试号;
|
||||
- AppSecret 是否已被重置但 Worker 仍保存旧值;
|
||||
- 分享页面是否经过了改变 URL 的代理或重定向;
|
||||
- 测试微信是否已关注测试号。
|
||||
|
||||
### 自定义域名绑定失败
|
||||
|
||||
检查同名 A、AAAA、CNAME 记录是否已经存在,域名是否位于当前 Wrangler 登录的 Cloudflare 账号中。
|
||||
|
||||
### R2 未配置
|
||||
|
||||
确认 Bucket 存在,并且 `wrangler.jsonc` 中的绑定名称为 `REPORTS`、`bucket_name` 与实际 Bucket 一致。
|
||||
|
||||
### 卡片过期或图片消失
|
||||
|
||||
默认有效期为 7 天。Worker 的定时任务会删除过期卡片的元数据、日报和缩略图,这是设计行为。
|
||||
|
||||
## 安全和隐私注意事项
|
||||
|
||||
- 日报可能包含敏感群聊内容。只分享你有权分享的内容。
|
||||
- 获得分享 URL 的人,在过期前可能查看对应日报;当前实现不是按访问者身份授权。
|
||||
- `UPLOAD_TOKEN` 是整个 Worker 的服务级密钥,不是每个用户独立的账号凭据。
|
||||
- 不要把 `UPLOAD_TOKEN`、AppSecret 或 Wrangler 凭据提交到 Git。
|
||||
- R2 保持私有,不要把 Bucket 直接公开。
|
||||
- 建议定期轮换 `UPLOAD_TOKEN`,怀疑泄露时立即轮换。
|
||||
- 微信 AppSecret 泄露时,应在微信后台重置,并立即更新 Worker Secret。
|
||||
- 自托管者自行承担 Cloudflare 用量、域名、数据合规和微信平台规则相关责任。
|
||||
|
||||
## 当前实验性限制
|
||||
|
||||
- 需要用户自己部署,普通用户无法直接开箱使用;
|
||||
- 使用一个共享的 `UPLOAD_TOKEN`,没有多用户账号系统;
|
||||
- 分享链接在有效期内属于“知道链接即可访问”;
|
||||
- 依赖微信 JS-SDK 和测试号能力,微信侧规则变化可能造成失效;
|
||||
- 当前仅上传 PNG 日报和 JPEG 缩略图;
|
||||
- 没有管理后台用于列出、提前删除或审计所有卡片;
|
||||
- 过期清理由定时任务完成,不保证到期瞬间立即删除。
|
||||
|
||||
## 代码入口
|
||||
|
||||
- Worker:`services/share-card-worker/src/index.js`
|
||||
- Worker 配置:`services/share-card-worker/wrangler.jsonc`
|
||||
- Worker 测试:`services/share-card-worker/test/index.test.js`
|
||||
- 桌面端上传:`src/main/wechat-share-card-service.ts`
|
||||
- 本地加密配置:`src/main/wechat-share-config-store.ts`
|
||||
- 分享弹窗:`src/renderer/src/components/reports/WechatShareCardDialog.tsx`
|
||||
- 共享类型:`src/shared/wechat-share-card.ts`
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)
|
||||
- [微信 JS-SDK 官方文档](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html)
|
||||
- [Cloudflare Wrangler 命令文档](https://developers.cloudflare.com/workers/wrangler/commands/)
|
||||
- [Cloudflare R2 Wrangler 命令](https://developers.cloudflare.com/workers/wrangler/commands/r2/)
|
||||
- [在 Worker 中绑定和使用 R2](https://developers.cloudflare.com/r2/api/workers/workers-api-usage/)
|
||||
- [Cloudflare Worker Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: setup-wechat-share-card
|
||||
description: 自动配置和部署 TraceMemo 实验性微信分享卡片服务。用户要求启用、部署、修复或迁移微信分享卡片,配置 Cloudflare Worker/R2/Wrangler,设置 UPLOAD_TOKEN、微信测试号 AppID/AppSecret、JS 接口安全域名,或希望由 Codex、Claude Code 等 Agent 代替手工阅读部署文档时使用。
|
||||
---
|
||||
|
||||
# 部署微信分享卡片
|
||||
|
||||
尽量自行发现项目状态并完成部署。只在自动检查后仍缺少必要信息时,一次性询问用户;不要逐项反复确认。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 不把真实 AppSecret、`UPLOAD_TOKEN`、Cloudflare Token 或微信验证内容写入 Git。
|
||||
- `.env.example` 只保存占位符;真实值写入项目根目录 `.env`,该文件必须被 Git 忽略。
|
||||
- 不在最终回答中回显 Secret。日志中只报告“已配置/缺失”。
|
||||
- `UPLOAD_TOKEN` 默认自动生成,不要求用户提供。
|
||||
- AppID/AppSecret 必须来自用户自己的微信测试号或公众号。无法自动获取时才询问。
|
||||
- 部署、创建 R2 和写 Secret 属于用户明确请求本 Skill 后的正常动作;不要提交、推送或创建 PR,除非用户另外明确要求。
|
||||
|
||||
## 自动工作流
|
||||
|
||||
1. 定位 TraceMemo 仓库根目录。确认存在 `services/share-card-worker/wrangler.jsonc`。
|
||||
2. 运行:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh doctor
|
||||
```
|
||||
|
||||
3. 检查根目录 `.env`。脚本会自动复用已有配置并生成缺失的 `WECHAT_SHARE_UPLOAD_TOKEN`。
|
||||
4. 如果以下值缺失,只向用户发起一次集中询问:
|
||||
- 分享域名,例如 `share.example.com`;
|
||||
- 微信测试号 AppID;
|
||||
- 微信测试号 AppSecret。
|
||||
5. 用户不知道从哪里获取时,告诉他打开:
|
||||
|
||||
```text
|
||||
https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index
|
||||
```
|
||||
|
||||
使用微信扫码登录后,复制页面上的 `appID` 和 `appsecret`。提醒用户把分享域名填入“JS 接口安全域名”,不带 `https://` 和路径。
|
||||
6. 将缺失值交给交互脚本,不要把 Secret 放进命令行参数:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh configure
|
||||
```
|
||||
|
||||
该命令通过终端交互收集缺项,AppSecret 使用隐藏输入。
|
||||
7. 执行完整部署:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh deploy
|
||||
```
|
||||
|
||||
脚本会依次:
|
||||
- 检查或临时下载 Wrangler;
|
||||
- 启动 Cloudflare OAuth 登录;
|
||||
- 执行 `whoami`;
|
||||
- 生成本地 `wrangler.local.jsonc`;
|
||||
- 创建或复用 R2 Bucket;
|
||||
- 写入三个 Worker Secret;
|
||||
- 部署 Worker;
|
||||
- 检查 `/health` 和微信签名接口。
|
||||
8. OAuth 页面出现时,让用户只完成浏览器登录/授权;不要改用 API Token,除非用户主动要求。
|
||||
9. 部署后把服务地址告诉用户,并提醒他在 TraceMemo 卡片弹窗粘贴 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN`。优先把 Token 复制到剪贴板,不在聊天中展示:
|
||||
|
||||
```bash
|
||||
bash docs/skill/setup-wechat-share-card/scripts/setup.sh copy-token
|
||||
```
|
||||
|
||||
10. 如果微信要求 TXT 验证文件,读取 [references/wechat-domain-verification.md](references/wechat-domain-verification.md),取得用户提供的文件后再修改 Worker。
|
||||
|
||||
## 决策规则
|
||||
|
||||
- Wrangler 未安装:优先使用项目依赖;否则通过 `pnpm dlx wrangler@latest` 临时下载,不强制全局安装。
|
||||
- `whoami` 已登录正确账号:不要重复登录。
|
||||
- R2 已存在:继续,不把“已存在”视为失败。
|
||||
- 自定义域名有 A/AAAA/CNAME 冲突:报告准确域名并要求用户选择删除冲突记录或换子域名;不要擅自删除 DNS。
|
||||
- HTTP 401:重新同步 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN` 到 Worker,再让用户更新 TraceMemo。
|
||||
- “微信 JS-SDK 尚未配置”:重新写入 AppID/AppSecret 并部署。
|
||||
- 微信返回 AppID/AppSecret 错误:让用户检查是否来自同一个测试号、AppSecret 是否已重置。
|
||||
- 缺少 JS 接口安全域名或测试号关注:这是微信后台操作,明确告诉用户要填写什么,不要假装已完成。
|
||||
|
||||
## 验证结果
|
||||
|
||||
完成前必须确认:
|
||||
|
||||
- `wrangler whoami` 成功;
|
||||
- Worker 部署成功;
|
||||
- `/health` 返回 `storage: ready`;
|
||||
- `/api/wx-signature` 返回 `appId`、`timestamp`、`nonceStr`、`signature`;
|
||||
- Git 扫描未发现 `.env`、真实 AppSecret、上传密钥或用户域名被暂存。
|
||||
|
||||
详细产品和架构说明见:`docs/deployment/experimental-wechat-share-card.md`。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "部署微信分享卡片"
|
||||
short_description: "让 Agent 自动完成微信分享卡片服务的配置与部署"
|
||||
default_prompt: "Use $setup-wechat-share-card to configure and deploy my self-hosted WeChat share-card service with minimal questions."
|
||||
@@ -0,0 +1,15 @@
|
||||
# 微信域名验证文件
|
||||
|
||||
当微信测试号页面要求下载 TXT 文件时:
|
||||
|
||||
1. 向用户索取 TXT 文件本身,或文件名与完整内容。
|
||||
2. 不把真实验证内容写进公开仓库历史。
|
||||
3. 优先在本地私有配置中注入;若当前 Worker 只能通过源码 Map 返回,则先提醒用户该值会进入工作区,确认仓库发布前必须移除或改造成 Secret/变量。
|
||||
4. 验证目标必须是:
|
||||
|
||||
```text
|
||||
https://<分享域名>/<微信提供的文件名>.txt
|
||||
```
|
||||
|
||||
5. 返回内容必须是纯文本且与微信提供内容完全一致,不加空格、HTML 或额外换行。
|
||||
6. 完成验证后再配置 JS 接口安全域名。安全域名只填主机名,不带协议或路径。
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
ENV_FILE="$REPO_ROOT/.env"
|
||||
EXAMPLE_FILE="$REPO_ROOT/.env.example"
|
||||
WORKER_DIR="$REPO_ROOT/services/share-card-worker"
|
||||
BASE_CONFIG="$WORKER_DIR/wrangler.jsonc"
|
||||
LOCAL_CONFIG="$WORKER_DIR/wrangler.local.jsonc"
|
||||
BUCKET_NAME="wechatexplorer-share-reports"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
info() {
|
||||
printf '[share-card] %s\n' "$*"
|
||||
}
|
||||
|
||||
require_project() {
|
||||
[[ -f "$BASE_CONFIG" ]] || fail "请在 TraceMemo 仓库根目录运行此脚本"
|
||||
[[ -f "$EXAMPLE_FILE" ]] || fail "缺少 .env.example"
|
||||
}
|
||||
|
||||
ensure_env_file() {
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp "$EXAMPLE_FILE" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
info "已从 .env.example 创建本机 .env"
|
||||
fi
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local line
|
||||
line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
|
||||
printf '%s' "${line#*=}"
|
||||
}
|
||||
|
||||
write_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local escaped
|
||||
escaped="$(printf '%s' "$value" | sed 's/[\\&|]/\\&/g')"
|
||||
if grep -q -E "^${key}=" "$ENV_FILE"; then
|
||||
sed -i.bak -E "s|^${key}=.*$|${key}=${escaped}|" "$ENV_FILE"
|
||||
command rm "$ENV_FILE.bak"
|
||||
else
|
||||
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
|
||||
fi
|
||||
chmod 600 "$ENV_FILE"
|
||||
}
|
||||
|
||||
normalize_domain() {
|
||||
local value="$1"
|
||||
value="${value#http://}"
|
||||
value="${value#https://}"
|
||||
value="${value%%/*}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
ensure_upload_token() {
|
||||
local token
|
||||
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
if [[ ${#token} -lt 32 ]]; then
|
||||
token="$(openssl rand -hex 32)"
|
||||
write_env_value WECHAT_SHARE_UPLOAD_TOKEN "$token"
|
||||
info "已生成新的 UPLOAD_TOKEN 并安全写入 .env"
|
||||
fi
|
||||
}
|
||||
|
||||
wrangler() {
|
||||
if [[ -x "$REPO_ROOT/node_modules/.bin/wrangler" ]]; then
|
||||
"$REPO_ROOT/node_modules/.bin/wrangler" "$@"
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm dlx wrangler@latest "$@"
|
||||
elif command -v npx >/dev/null 2>&1; then
|
||||
npx --yes wrangler@latest "$@"
|
||||
else
|
||||
fail "需要 Node.js 以及 pnpm 或 npm 才能运行 Wrangler"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_wrangler_login() {
|
||||
if wrangler whoami >/dev/null 2>&1; then
|
||||
wrangler whoami
|
||||
return
|
||||
fi
|
||||
info "即将打开 Cloudflare OAuth 登录,请在浏览器中完成授权"
|
||||
wrangler login
|
||||
wrangler whoami
|
||||
}
|
||||
|
||||
validate_required_config() {
|
||||
local domain app_id app_secret token
|
||||
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
[[ -n "$domain" && "$domain" != "share.example.com" ]] || fail "缺少真实 WECHAT_SHARE_DOMAIN"
|
||||
[[ -n "$app_id" ]] || fail "缺少 WECHAT_SHARE_APP_ID"
|
||||
[[ -n "$app_secret" ]] || fail "缺少 WECHAT_SHARE_APP_SECRET"
|
||||
[[ ${#token} -ge 32 ]] || fail "WECHAT_SHARE_UPLOAD_TOKEN 长度不足"
|
||||
}
|
||||
|
||||
configure_interactively() {
|
||||
ensure_env_file
|
||||
local domain app_id app_secret
|
||||
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
|
||||
if [[ -z "$domain" || "$domain" == "share.example.com" ]]; then
|
||||
read -r -p '分享域名(例如 share.example.com,不带 https://):' domain
|
||||
domain="$(normalize_domain "$domain")"
|
||||
[[ -n "$domain" ]] || fail "分享域名不能为空"
|
||||
write_env_value WECHAT_SHARE_DOMAIN "$domain"
|
||||
fi
|
||||
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
if [[ -z "$app_id" ]]; then
|
||||
read -r -p '微信测试号 AppID:' app_id
|
||||
[[ -n "$app_id" ]] || fail "AppID 不能为空"
|
||||
write_env_value WECHAT_SHARE_APP_ID "$app_id"
|
||||
fi
|
||||
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
if [[ -z "$app_secret" ]]; then
|
||||
read -r -s -p '微信测试号 AppSecret(输入不会显示):' app_secret
|
||||
printf '\n'
|
||||
[[ -n "$app_secret" ]] || fail "AppSecret 不能为空"
|
||||
write_env_value WECHAT_SHARE_APP_SECRET "$app_secret"
|
||||
fi
|
||||
|
||||
ensure_upload_token
|
||||
info "本机配置已准备完成"
|
||||
}
|
||||
|
||||
generate_local_config() {
|
||||
local domain
|
||||
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
|
||||
cat > "$LOCAL_CONFIG" <<EOF
|
||||
{
|
||||
"\$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "wechatexplorer-share-card",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-07-23",
|
||||
"routes": [{ "pattern": "$domain", "custom_domain": true }],
|
||||
"r2_buckets": [{ "binding": "REPORTS", "bucket_name": "$BUCKET_NAME" }],
|
||||
"triggers": { "crons": ["17 3 * * *"] },
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://$domain",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
info "已生成本机 Worker 配置 services/share-card-worker/wrangler.local.jsonc"
|
||||
}
|
||||
|
||||
assert_secrets_not_tracked() {
|
||||
git check-ignore -q .env || fail ".env 未被 Git 忽略,请停止部署并检查 .gitignore"
|
||||
if git ls-files --error-unmatch .env >/dev/null 2>&1; then
|
||||
fail ".env 已被 Git 跟踪,请先从索引移除"
|
||||
fi
|
||||
if git diff --cached --name-only | grep -Eq '(^|/)\.env$|wrangler\.local\.jsonc$'; then
|
||||
fail "敏感本机配置已被暂存,请先取消暂存"
|
||||
fi
|
||||
}
|
||||
|
||||
create_bucket_if_needed() {
|
||||
local output
|
||||
set +e
|
||||
output="$(wrangler r2 bucket create "$BUCKET_NAME" --config "$LOCAL_CONFIG" 2>&1)"
|
||||
local status=$?
|
||||
set -e
|
||||
if [[ $status -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
elif printf '%s' "$output" | grep -Eqi 'already exists|already owned|10004'; then
|
||||
info "R2 Bucket 已存在,继续部署"
|
||||
else
|
||||
printf '%s\n' "$output" >&2
|
||||
fail "创建 R2 Bucket 失败"
|
||||
fi
|
||||
}
|
||||
|
||||
put_secrets() {
|
||||
local upload_token app_id app_secret
|
||||
upload_token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
printf '%s' "$upload_token" | wrangler secret put UPLOAD_TOKEN --config "$LOCAL_CONFIG"
|
||||
printf '%s' "$app_id" | wrangler secret put WECHAT_APP_ID --config "$LOCAL_CONFIG"
|
||||
printf '%s' "$app_secret" | wrangler secret put WECHAT_APP_SECRET --config "$LOCAL_CONFIG"
|
||||
}
|
||||
|
||||
verify_service() {
|
||||
local domain health signature
|
||||
domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")"
|
||||
health="$(curl -fsS --retry 5 --retry-delay 2 "https://$domain/health")"
|
||||
printf '%s' "$health" | grep -q '"storage":"ready"' || fail "健康检查未返回 storage: ready"
|
||||
signature="$(curl -fsS --retry 3 --retry-delay 2 "https://$domain/api/wx-signature?url=https%3A%2F%2F${domain}%2Fhealth")"
|
||||
printf '%s' "$signature" | grep -q '"signature"' || fail "微信 JS-SDK 签名检查失败:$signature"
|
||||
info "服务验证成功:https://$domain"
|
||||
}
|
||||
|
||||
doctor() {
|
||||
require_project
|
||||
ensure_env_file
|
||||
ensure_upload_token
|
||||
assert_secrets_not_tracked
|
||||
info "Node: $(node --version 2>/dev/null || printf '未安装')"
|
||||
info "pnpm: $(pnpm --version 2>/dev/null || printf '未安装')"
|
||||
if wrangler --version >/dev/null 2>&1; then
|
||||
info "Wrangler 可用:$(wrangler --version | tail -n 1)"
|
||||
else
|
||||
fail "Wrangler 无法运行"
|
||||
fi
|
||||
local domain app_id app_secret
|
||||
domain="$(read_env_value WECHAT_SHARE_DOMAIN)"
|
||||
app_id="$(read_env_value WECHAT_SHARE_APP_ID)"
|
||||
app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)"
|
||||
[[ -n "$domain" && "$domain" != "share.example.com" ]] && info "分享域名:已配置" || info "分享域名:缺失"
|
||||
[[ -n "$app_id" ]] && info "微信 AppID:已配置" || info "微信 AppID:缺失"
|
||||
[[ -n "$app_secret" ]] && info "微信 AppSecret:已配置" || info "微信 AppSecret:缺失"
|
||||
info "UPLOAD_TOKEN:已配置"
|
||||
}
|
||||
|
||||
deploy() {
|
||||
require_project
|
||||
ensure_env_file
|
||||
ensure_upload_token
|
||||
validate_required_config
|
||||
assert_secrets_not_tracked
|
||||
ensure_wrangler_login
|
||||
generate_local_config
|
||||
create_bucket_if_needed
|
||||
put_secrets
|
||||
wrangler deploy --config "$LOCAL_CONFIG"
|
||||
verify_service
|
||||
}
|
||||
|
||||
copy_token() {
|
||||
ensure_env_file
|
||||
ensure_upload_token
|
||||
local token
|
||||
token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)"
|
||||
if command -v pbcopy >/dev/null 2>&1; then
|
||||
printf '%s' "$token" | pbcopy
|
||||
elif command -v wl-copy >/dev/null 2>&1; then
|
||||
printf '%s' "$token" | wl-copy
|
||||
elif command -v xclip >/dev/null 2>&1; then
|
||||
printf '%s' "$token" | xclip -selection clipboard
|
||||
else
|
||||
fail "未找到剪贴板工具;请让用户自行从 .env 读取 WECHAT_SHARE_UPLOAD_TOKEN"
|
||||
fi
|
||||
info "UPLOAD_TOKEN 已复制到剪贴板"
|
||||
}
|
||||
|
||||
case "${1:-doctor}" in
|
||||
doctor) doctor ;;
|
||||
configure) configure_interactively ;;
|
||||
deploy) deploy ;;
|
||||
copy-token) copy_token ;;
|
||||
*) fail "用法:$0 {doctor|configure|deploy|copy-token}" ;;
|
||||
esac
|
||||
+674
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2026 yincongcyincong
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# wechat_chatter / OneBot 第三方组件说明
|
||||
|
||||
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
|
||||
|
||||
- 项目:`yincongcyincong/wechat_chatter`
|
||||
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
|
||||
- 当前运行时版本:`v0.0.18`
|
||||
- 运行时文件:`onebot_mac_arm64.tar.gz`
|
||||
- 许可证:GNU General Public License version 3(GPL-3.0)
|
||||
- 上游版权:Copyright (C) 2026 yincongcyincong
|
||||
- TraceMemo 修改日期:2026-08-17
|
||||
|
||||
## 集成方式
|
||||
|
||||
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
|
||||
|
||||
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
|
||||
|
||||
## 本地修改
|
||||
|
||||
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
|
||||
|
||||
- `scripts/prepare-wechat-chatter-runtime.cjs`
|
||||
- `src/main/services/personal-wechat-runtime-manager.ts`
|
||||
|
||||
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
|
||||
|
||||
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
|
||||
@@ -25,6 +25,7 @@ extraResources:
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- '!connectors/wechat-personal/**'
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
|
||||
@@ -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);
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tracememo",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.2",
|
||||
"packageManager": "pnpm@7.33.7",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
|
||||
"keywords": [
|
||||
@@ -34,6 +34,7 @@
|
||||
"cp:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||
@@ -78,6 +79,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 +97,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 +116,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 +136,4 @@
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+856
-14
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
@@ -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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
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'))
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ANCHOR='com.tencent.wechat.update'
|
||||
ANCHOR_FILE='/etc/pf.anchors/wechat-update'
|
||||
PF_CONF='/etc/pf.conf'
|
||||
BACKUP_DIR='/etc/pf.conf.wechat-backups'
|
||||
CACHE_DIR="${HOME}/Library/Caches/com.tencent.xinWeChat"
|
||||
|
||||
domains='dldir1.qq.com dldir2.qq.com dldir3.qq.com dldir1v6.qq.com'
|
||||
|
||||
die() { printf '%s\n' "error: $*" >&2; exit 1; }
|
||||
need_root() { [ "$(id -u)" -eq 0 ] || die '请使用 sudo 执行此脚本'; }
|
||||
|
||||
resolve_ips() {
|
||||
command -v dig >/dev/null 2>&1 || die '需要 dig(macOS 通常已内置)'
|
||||
for domain in $domains; do
|
||||
dig +short A "$domain"
|
||||
dig +short AAAA "$domain"
|
||||
done | awk '/^[0-9]+(\.[0-9]+){3}$/ || /^[0-9A-Fa-f:]+:[0-9A-Fa-f:]+/' | sort -u
|
||||
}
|
||||
|
||||
enable() {
|
||||
need_root
|
||||
ips="$(resolve_ips)"
|
||||
[ -n "$ips" ] || die '域名解析没有返回 IP,未修改 PF'
|
||||
mkdir -p /etc/pf.anchors "$BACKUP_DIR"
|
||||
backup="$BACKUP_DIR/pf.conf.$(date +%Y%m%d-%H%M%S)"
|
||||
cp -p "$PF_CONF" "$backup"
|
||||
{
|
||||
printf 'table <wechat_update> persist { '
|
||||
printf '%s' "$ips" | tr '\n' ' '
|
||||
printf '}\nblock drop out quick to <wechat_update>\n'
|
||||
} > "$ANCHOR_FILE"
|
||||
if ! grep -Fq 'anchor "com.tencent.wechat.update"' "$PF_CONF"; then
|
||||
printf '\n# TraceMemo: block WeChat update endpoints\nanchor "com.tencent.wechat.update"\nload anchor "com.tencent.wechat.update" from "/etc/pf.anchors/wechat-update"\n' >> "$PF_CONF"
|
||||
fi
|
||||
pfctl -vnf "$PF_CONF"
|
||||
pfctl -f "$PF_CONF"
|
||||
pfctl -E >/dev/null 2>&1 || true
|
||||
printf '已启用微信更新拦截,规则备份:%s\n' "$backup"
|
||||
printf '当前 IP:\n%s\n' "$ips"
|
||||
}
|
||||
|
||||
disable() {
|
||||
need_root
|
||||
pfctl -a "$ANCHOR" -F all >/dev/null 2>&1 || true
|
||||
printf '已清空微信更新 PF anchor。要完全移除配置行,请从 /etc/pf.conf 删除 TraceMemo 标记的三行。\n'
|
||||
}
|
||||
|
||||
status() {
|
||||
pfctl -s info 2>&1 | head -20
|
||||
printf '\n微信更新 anchor:\n'
|
||||
pfctl -a "$ANCHOR" -sr 2>&1 || true
|
||||
printf '\n缓存目录:\n%s\n' "$CACHE_DIR"
|
||||
ls -ldO "$CACHE_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
enable) enable ;;
|
||||
disable) disable ;;
|
||||
status) status ;;
|
||||
*) die "用法:sudo $0 {enable|disable|status}" ;;
|
||||
esac
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* wechat_chatter runtime integration
|
||||
*
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* Upstream license: GNU General Public License version 3 (GPL-3.0)
|
||||
*
|
||||
* This file applies local compatibility patches to the upstream
|
||||
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const release = 'v0.0.18'
|
||||
const asset = 'onebot_mac_arm64.tar.gz'
|
||||
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const outputDir = path.join(
|
||||
projectRoot,
|
||||
'resources',
|
||||
'connectors',
|
||||
'wechat-personal',
|
||||
'darwin-arm64'
|
||||
)
|
||||
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
|
||||
const appleSilicon =
|
||||
process.platform === 'darwin' &&
|
||||
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
|
||||
|
||||
if (!appleSilicon) {
|
||||
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
let archiveReady = false
|
||||
if (fs.existsSync(archive)) {
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
|
||||
archiveReady = true
|
||||
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
|
||||
} catch {
|
||||
// The archive is partial or invalid; curl will resume it below.
|
||||
}
|
||||
}
|
||||
if (!archiveReady) {
|
||||
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
|
||||
execFileSync(
|
||||
'/usr/bin/curl',
|
||||
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
|
||||
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
|
||||
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
|
||||
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
|
||||
fs.copyFileSync(
|
||||
path.join(extractionDir, 'onebot', 'script.js'),
|
||||
path.join(outputDir, 'onebot', 'script.js')
|
||||
)
|
||||
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
|
||||
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
|
||||
fs.chmodSync(stagedExecutable, 0o755)
|
||||
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
|
||||
} finally {
|
||||
fs.rmSync(extractionDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const executable = path.join(outputDir, 'onebot', 'onebot')
|
||||
const script = path.join(outputDir, 'onebot', 'script.js')
|
||||
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
|
||||
for (const required of [executable, script, config]) {
|
||||
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
|
||||
}
|
||||
|
||||
const pythonPackages = path.join(outputDir, 'python')
|
||||
const pilkPackage = path.join(pythonPackages, 'pilk')
|
||||
if (!fs.existsSync(pilkPackage)) {
|
||||
console.log('[wechat-personal] 安装项目内语音编码器 pilk')
|
||||
fs.mkdirSync(pythonPackages, { recursive: true })
|
||||
try {
|
||||
execFileSync(
|
||||
'python3',
|
||||
[
|
||||
'-m',
|
||||
'pip',
|
||||
'install',
|
||||
'--disable-pip-version-check',
|
||||
'--no-compile',
|
||||
'--target',
|
||||
pythonPackages,
|
||||
'pilk==0.2.4'
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
} catch {
|
||||
console.warn('[wechat-personal] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器')
|
||||
}
|
||||
}
|
||||
|
||||
function patchPerSendPayload(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||
|
||||
const declarations = 'var triggerX1Payload;\nvar triggerX0;'
|
||||
const patchedDeclarations =
|
||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
|
||||
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
triggerX1Payload.writeByteArray(payloadData);
|
||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||
|
||||
if (!source.includes(declarations) || !source.includes(originalSend)) {
|
||||
throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||
}
|
||||
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
|
||||
source = source.replace(
|
||||
' MMStartTask(triggerX0, triggerX1Payload);',
|
||||
' MMStartTask(triggerX0, activeTriggerX1Payload);'
|
||||
)
|
||||
source = source.replace(
|
||||
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||
)
|
||||
source = source.replace(
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
|
||||
}
|
||||
|
||||
function patchImageHookReadiness(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (
|
||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||
source.includes('图片上传 Hook Setup Complete')
|
||||
)
|
||||
return
|
||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||
\t\t\t}
|
||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
|
||||
source = source.replace(original, patched)
|
||||
source = source.replace(
|
||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
||||
}
|
||||
|
||||
function patchWechatCoreModuleBase(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('WeChat core module base:')) return
|
||||
const initMarker = 'function initAddresses() {'
|
||||
const initIndex = source.indexOf(initMarker)
|
||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
|
||||
}
|
||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||
var module = Process.enumerateModules().find(function(m) {
|
||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||
});
|
||||
if (!module) {
|
||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||
}
|
||||
var moduleBase = module.base;
|
||||
var baseAddr = moduleBase;
|
||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||
setImmediate(initAddresses);
|
||||
|
||||
`
|
||||
source = patchedHeader + source.slice(initIndex)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
|
||||
}
|
||||
|
||||
function addModifiedWorkNotice(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||
|
||||
const notice = `/*
|
||||
* TraceMemo wechat_chatter compatibility modifications
|
||||
* Modified: 2026-08-17
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* License: GNU General Public License version 3 (GPL-3.0)
|
||||
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
|
||||
* These modifications are not provided by the upstream author.
|
||||
*/
|
||||
|
||||
`
|
||||
source = notice + source
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已写入 GPL 修改声明')
|
||||
}
|
||||
|
||||
patchWechatCoreModuleBase(script)
|
||||
patchPerSendPayload(script)
|
||||
patchImageHookReadiness(script)
|
||||
addModifiedWorkNotice(script)
|
||||
fs.chmodSync(executable, 0o755)
|
||||
console.log('[wechat-personal] 运行时准备完成')
|
||||
@@ -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: '',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# WechatExplorer share-card worker
|
||||
|
||||
Cloudflare Worker + private R2 service for temporary WeChat report cards.
|
||||
|
||||
This is an experimental, self-hosted feature. See the complete Chinese deployment guide:
|
||||
|
||||
- `../../docs/deployment/experimental-wechat-share-card.md`
|
||||
|
||||
Required encrypted secrets:
|
||||
|
||||
- `WECHAT_APP_ID`
|
||||
- `WECHAT_APP_SECRET`
|
||||
- `UPLOAD_TOKEN` (random 32+ character token also saved in WechatExplorer's secure settings)
|
||||
|
||||
Create the private bucket, set secrets, and deploy:
|
||||
|
||||
```bash
|
||||
npx wrangler r2 bucket create wechatexplorer-share-reports
|
||||
npx wrangler secret put WECHAT_APP_ID
|
||||
npx wrangler secret put WECHAT_APP_SECRET
|
||||
npx wrangler secret put UPLOAD_TOKEN
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
Keep the R2 public development URL disabled. All reads go through the Worker and expire with
|
||||
the card.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "wechatexplorer-share-card-worker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"check": "node --check src/index.js && node --test test/index.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.28.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
const encoder = new TextEncoder()
|
||||
const DAY_MS = 86_400_000
|
||||
// Add the TXT filename and content supplied by the WeChat test-account page when
|
||||
// domain verification is required. Never commit a real verification value.
|
||||
const WECHAT_DOMAIN_VERIFICATION = new Map()
|
||||
|
||||
const json = (value, init = {}) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', ...(init.headers || {}) }
|
||||
})
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
|
||||
const safeJson = (value) => JSON.stringify(value).replaceAll('<', '\\u003c')
|
||||
|
||||
const cardKey = (id) => `cards/${id}/card.json`
|
||||
const imageKey = (id) => `cards/${id}/report.png`
|
||||
const thumbnailKey = (id) => `cards/${id}/thumbnail.jpg`
|
||||
|
||||
const storageUnavailable = () =>
|
||||
json(
|
||||
{
|
||||
error: '图片存储服务尚未启用,请在 Cloudflare 控制台启用 R2 后重新生成分享卡片'
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
|
||||
const readCard = async (env, id) => {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) return null
|
||||
const object = await env.REPORTS.get(cardKey(id))
|
||||
if (!object) return null
|
||||
const card = JSON.parse(await object.text())
|
||||
if (Date.parse(card.expiresAt) <= Date.now()) {
|
||||
await deleteCard(env, id)
|
||||
return null
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
const deleteCard = async (env, id) => {
|
||||
await env.REPORTS.delete([cardKey(id), imageKey(id), thumbnailKey(id)])
|
||||
}
|
||||
|
||||
const bearerAuthorized = (request, env) => {
|
||||
const value = request.headers.get('authorization') || ''
|
||||
return Boolean(env.UPLOAD_TOKEN) && value === `Bearer ${env.UPLOAD_TOKEN}`
|
||||
}
|
||||
|
||||
const publicOrigin = (request, env) =>
|
||||
String(env.PUBLIC_ORIGIN || new URL(request.url).origin).replace(/\/+$/, '')
|
||||
|
||||
const createCard = async (request, env) => {
|
||||
if (!bearerAuthorized(request, env)) return json({ error: '未授权' }, { status: 401 })
|
||||
const body = await request.json().catch(() => null)
|
||||
if (!body) return json({ error: '请求体无效' }, { status: 400 })
|
||||
const title = String(body.title || '')
|
||||
.trim()
|
||||
.slice(0, 64)
|
||||
const description = String(body.description || '')
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
if (!title || !body.imageBase64 || !body.thumbnailBase64) {
|
||||
return json({ error: '缺少标题或图片' }, { status: 400 })
|
||||
}
|
||||
const image = Uint8Array.from(atob(body.imageBase64), (char) => char.charCodeAt(0))
|
||||
const thumbnail = Uint8Array.from(atob(body.thumbnailBase64), (char) => char.charCodeAt(0))
|
||||
if (image.byteLength > 25 * 1024 * 1024 || thumbnail.byteLength > 2 * 1024 * 1024) {
|
||||
return json({ error: '图片超过大小限制' }, { status: 413 })
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const days = Math.max(1, Math.min(30, Number(body.expiresInDays || env.DEFAULT_EXPIRY_DAYS || 7)))
|
||||
const createdAt = new Date().toISOString()
|
||||
const expiresAt = new Date(Date.now() + days * DAY_MS).toISOString()
|
||||
const card = { id, title, description, createdAt, expiresAt }
|
||||
await Promise.all([
|
||||
env.REPORTS.put(cardKey(id), JSON.stringify(card), {
|
||||
httpMetadata: { contentType: 'application/json; charset=utf-8' }
|
||||
}),
|
||||
env.REPORTS.put(imageKey(id), image, {
|
||||
httpMetadata: { contentType: 'image/png', cacheControl: 'private, max-age=300' }
|
||||
}),
|
||||
env.REPORTS.put(thumbnailKey(id), thumbnail, {
|
||||
httpMetadata: { contentType: 'image/jpeg', cacheControl: 'public, max-age=300' }
|
||||
})
|
||||
])
|
||||
const origin = publicOrigin(request, env)
|
||||
return json({
|
||||
cardId: id,
|
||||
shareUrl: `${origin}/s/${id}`,
|
||||
viewUrl: `${origin}/v/${id}`,
|
||||
expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
const serveAsset = async (env, id, kind) => {
|
||||
const card = await readCard(env, id)
|
||||
if (!card) return new Response('Not found', { status: 404 })
|
||||
const object = await env.REPORTS.get(kind === 'thumbnail' ? thumbnailKey(id) : imageKey(id))
|
||||
if (!object) return new Response('Not found', { status: 404 })
|
||||
const headers = new Headers()
|
||||
object.writeHttpMetadata(headers)
|
||||
headers.set('x-content-type-options', 'nosniff')
|
||||
headers.set('cache-control', kind === 'thumbnail' ? 'public, max-age=300' : 'private, max-age=60')
|
||||
return new Response(object.body, { headers })
|
||||
}
|
||||
|
||||
const sharePage = (request, env, card) => {
|
||||
const origin = publicOrigin(request, env)
|
||||
const viewUrl = `${origin}/v/${card.id}`
|
||||
const cardLinkUrl = `${origin}/l/${card.id}`
|
||||
const imageUrl = `${origin}/a/${card.id}/thumbnail`
|
||||
const share = { title: card.title, desc: card.description, link: cardLinkUrl, imgUrl: imageUrl }
|
||||
return new Response(
|
||||
`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta property="og:title" content="${escapeHtml(card.title)}">
|
||||
<meta property="og:description" content="${escapeHtml(card.description)}">
|
||||
<meta property="og:image" content="${escapeHtml(imageUrl)}">
|
||||
<title>${escapeHtml(card.title)}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f3f6f5;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
main{min-height:100vh;padding:48px 24px;display:flex;align-items:center;justify-content:center}
|
||||
.card{width:min(100%,440px);background:#fff;border-radius:24px;padding:30px;box-shadow:0 18px 60px rgba(23,32,29,.10);text-align:center}
|
||||
.arrow{font-size:52px;color:#16a66a;transform:rotate(-20deg);margin:-10px 0 12px}
|
||||
h1{font-size:23px;margin:0 0 12px}.desc{color:#61706a;line-height:1.7;margin:0 0 28px}
|
||||
.hint{background:#ecf8f2;border:1px solid #cdebdc;border-radius:16px;padding:18px;line-height:1.7}
|
||||
.open{display:inline-block;margin-top:22px;color:#08794c;text-decoration:none;font-weight:650}
|
||||
#status{font-size:13px;color:#7f8d87;margin-top:18px}
|
||||
</style>
|
||||
</head>
|
||||
<body><main><section class="card">
|
||||
<div class="arrow">↗</div>
|
||||
<h1>点击右上角 ··· 分享</h1>
|
||||
<p class="desc">${escapeHtml(card.description)}</p>
|
||||
<div class="hint">发送给好友或群聊后,将显示为标题、描述和缩略图组成的微信卡片。</div>
|
||||
<a class="open" href="${escapeHtml(viewUrl)}">先查看完整日报</a>
|
||||
<p id="status">正在准备微信分享信息…</p>
|
||||
</section></main>
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||
<script>
|
||||
const share=${safeJson(share)};
|
||||
const status=document.getElementById('status');
|
||||
fetch('/api/wx-signature?url='+encodeURIComponent(location.href.split('#')[0]))
|
||||
.then(r=>r.json().then(data=>({ok:r.ok,data})))
|
||||
.then(({ok,data})=>{
|
||||
if(!ok) throw new Error(data.error||'签名失败');
|
||||
wx.config({...data,debug:false,jsApiList:['updateAppMessageShareData','updateTimelineShareData']});
|
||||
wx.ready(()=>{
|
||||
wx.updateAppMessageShareData({...share,success:()=>status.textContent='分享卡片已准备好'});
|
||||
wx.updateTimelineShareData({title:share.title,link:share.link,imgUrl:share.imgUrl});
|
||||
status.textContent='分享卡片已准备好';
|
||||
});
|
||||
wx.error(err=>{status.textContent='微信分享配置失败:'+(err.errMsg||'未知错误')});
|
||||
})
|
||||
.catch(err=>{status.textContent='微信分享配置失败:'+err.message});
|
||||
</script></body></html>`,
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
'content-security-policy':
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' https://res.wx.qq.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const viewPage = (env, card) => {
|
||||
const origin = String(env.PUBLIC_ORIGIN).replace(/\/+$/, '')
|
||||
return new Response(
|
||||
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(card.title)}</title><style>*{box-sizing:border-box}body{margin:0;background:#eef2f0;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{padding:20px;background:#fff;position:sticky;top:0;box-shadow:0 1px 8px #0001}h1{font-size:18px;margin:0 0 6px}p{margin:0;color:#68746f;font-size:13px}.image{display:block;width:min(100%,900px);height:auto;margin:20px auto;background:#fff}</style></head><body><header><h1>${escapeHtml(card.title)}</h1><p>${escapeHtml(card.description)} · 有效期至 ${escapeHtml(card.expiresAt.slice(0, 10))}</p></header><img class="image" src="${origin}/a/${card.id}/report" alt="${escapeHtml(card.title)}"></body></html>`,
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
'content-security-policy': "default-src 'self'; img-src 'self'; style-src 'unsafe-inline'"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const cachedWechatValue = async (cacheKey, ttl, loader) => {
|
||||
const cache = caches.default
|
||||
const request = new Request(`https://wechat-cache.invalid/${cacheKey}`)
|
||||
const cached = await cache.match(request)
|
||||
if (cached) return cached.json()
|
||||
const value = await loader()
|
||||
await cache.put(request, json(value, { headers: { 'cache-control': `public, max-age=${ttl}` } }))
|
||||
return value
|
||||
}
|
||||
|
||||
const getAccessToken = (env) =>
|
||||
cachedWechatValue(`access-token/${env.WECHAT_APP_ID}`, 6900, async () => {
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/token')
|
||||
url.searchParams.set('grant_type', 'client_credential')
|
||||
url.searchParams.set('appid', env.WECHAT_APP_ID)
|
||||
url.searchParams.set('secret', env.WECHAT_APP_SECRET)
|
||||
const data = await fetch(url).then((response) => response.json())
|
||||
if (!data.access_token) throw new Error(data.errmsg || '无法获取 access_token')
|
||||
return { value: data.access_token }
|
||||
})
|
||||
|
||||
const getTicket = async (env) => {
|
||||
const token = await getAccessToken(env)
|
||||
return cachedWechatValue(`jsapi-ticket/${env.WECHAT_APP_ID}`, 6900, async () => {
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket')
|
||||
url.searchParams.set('access_token', token.value)
|
||||
url.searchParams.set('type', 'jsapi')
|
||||
const data = await fetch(url).then((response) => response.json())
|
||||
if (!data.ticket) throw new Error(data.errmsg || '无法获取 jsapi_ticket')
|
||||
return { value: data.ticket }
|
||||
})
|
||||
}
|
||||
|
||||
const sha1 = async (value) => {
|
||||
const digest = await crypto.subtle.digest('SHA-1', encoder.encode(value))
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
const signature = async (request, env) => {
|
||||
if (!env.WECHAT_APP_ID || !env.WECHAT_APP_SECRET) {
|
||||
return json({ error: '微信 JS-SDK 尚未配置' }, { status: 503 })
|
||||
}
|
||||
const pageUrl = new URL(request.url).searchParams.get('url')
|
||||
if (!pageUrl) return json({ error: '缺少签名 URL' }, { status: 400 })
|
||||
const parsed = new URL(pageUrl)
|
||||
if (parsed.origin !== publicOrigin(request, env)) {
|
||||
return json({ error: '只能签名当前分享域名' }, { status: 400 })
|
||||
}
|
||||
const ticket = await getTicket(env)
|
||||
const nonceStr = crypto.randomUUID().replaceAll('-', '')
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const source = `jsapi_ticket=${ticket.value}&noncestr=${nonceStr}×tamp=${timestamp}&url=${pageUrl}`
|
||||
return json({
|
||||
appId: env.WECHAT_APP_ID,
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature: await sha1(source)
|
||||
})
|
||||
}
|
||||
|
||||
const router = async (request, env) => {
|
||||
const url = new URL(request.url)
|
||||
const verificationContent = WECHAT_DOMAIN_VERIFICATION.get(url.pathname.slice(1))
|
||||
if (request.method === 'GET' && verificationContent) {
|
||||
return new Response(verificationContent, {
|
||||
headers: {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
'x-content-type-options': 'nosniff'
|
||||
}
|
||||
})
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
return json({
|
||||
ok: true,
|
||||
service: 'wechatexplorer-share-card',
|
||||
storage: env.REPORTS ? 'ready' : 'unavailable'
|
||||
})
|
||||
}
|
||||
if (!env.REPORTS && (url.pathname === '/api/cards' || /^\/(s|l|v|a)\//i.test(url.pathname))) {
|
||||
return storageUnavailable()
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/api/cards') return createCard(request, env)
|
||||
if (request.method === 'GET' && url.pathname === '/api/wx-signature') {
|
||||
try {
|
||||
return await signature(request, env)
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
const match = url.pathname.match(/^\/(s|l|v|a)\/([0-9a-f-]{36})(?:\/(report|thumbnail))?$/i)
|
||||
if (!match) return new Response('Not found', { status: 404 })
|
||||
const [, route, id, asset] = match
|
||||
if (route === 'a') return serveAsset(env, id, asset)
|
||||
const card = await readCard(env, id)
|
||||
if (!card) return new Response('卡片不存在或已过期', { status: 404 })
|
||||
if (route === 'l') {
|
||||
return Response.redirect(`${publicOrigin(request, env)}/v/${card.id}`, 302)
|
||||
}
|
||||
return route === 's' ? sharePage(request, env, card) : viewPage(env, card)
|
||||
}
|
||||
|
||||
const cleanup = async (env) => {
|
||||
let cursor
|
||||
do {
|
||||
const listed = await env.REPORTS.list({ prefix: 'cards/', cursor, include: ['httpMetadata'] })
|
||||
const metadataObjects = listed.objects.filter((object) => object.key.endsWith('/card.json'))
|
||||
for (const item of metadataObjects) {
|
||||
const object = await env.REPORTS.get(item.key)
|
||||
if (!object) continue
|
||||
const card = JSON.parse(await object.text())
|
||||
if (Date.parse(card.expiresAt) <= Date.now()) await deleteCard(env, card.id)
|
||||
}
|
||||
cursor = listed.truncated ? listed.cursor : undefined
|
||||
} while (cursor)
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch: (request, env) => router(request, env),
|
||||
scheduled: (_controller, env, ctx) => ctx.waitUntil(cleanup(env))
|
||||
}
|
||||
|
||||
export { escapeHtml, sha1 }
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import worker, { escapeHtml, sha1 } from '../src/index.js'
|
||||
|
||||
class MemoryR2Object {
|
||||
constructor(value, metadata = {}) {
|
||||
this.value = value
|
||||
this.metadata = metadata
|
||||
}
|
||||
|
||||
async text() {
|
||||
return new TextDecoder().decode(this.value)
|
||||
}
|
||||
|
||||
get body() {
|
||||
return this.value
|
||||
}
|
||||
|
||||
writeHttpMetadata(headers) {
|
||||
if (this.metadata.contentType) headers.set('content-type', this.metadata.contentType)
|
||||
if (this.metadata.cacheControl) headers.set('cache-control', this.metadata.cacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryR2 {
|
||||
objects = new Map()
|
||||
|
||||
async put(key, value, options = {}) {
|
||||
const bytes =
|
||||
typeof value === 'string' ? new TextEncoder().encode(value) : new Uint8Array(value)
|
||||
this.objects.set(key, new MemoryR2Object(bytes, options.httpMetadata))
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
return this.objects.get(key) || null
|
||||
}
|
||||
|
||||
async delete(keys) {
|
||||
for (const key of Array.isArray(keys) ? keys : [keys]) this.objects.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const env = () => ({
|
||||
REPORTS: new MemoryR2(),
|
||||
UPLOAD_TOKEN: 'test-upload-token-that-is-long-enough',
|
||||
PUBLIC_ORIGIN: 'https://share.example.com',
|
||||
DEFAULT_EXPIRY_DAYS: '7'
|
||||
})
|
||||
|
||||
test('requires the upload bearer token', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://share.example.com/api/cards', { method: 'POST', body: '{}' }),
|
||||
env()
|
||||
)
|
||||
assert.equal(response.status, 401)
|
||||
})
|
||||
|
||||
test('returns a controlled error when R2 is not configured', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://share.example/s/d9069d5a-d1a0-44fc-a983-2602c3f1cb94'),
|
||||
{ PUBLIC_ORIGIN: 'https://share.example' }
|
||||
)
|
||||
assert.equal(response.status, 503)
|
||||
assert.match(await response.text(), /图片存储服务尚未启用/)
|
||||
})
|
||||
|
||||
test('creates an expiring card and serves only its random assets', async () => {
|
||||
const testEnv = env()
|
||||
const response = await worker.fetch(
|
||||
new Request('https://share.example.com/api/cards', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${testEnv.UPLOAD_TOKEN}`,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '技术交流群日报',
|
||||
description: '今日群聊总结',
|
||||
imageBase64: Buffer.from('png-data').toString('base64'),
|
||||
thumbnailBase64: Buffer.from('jpeg-data').toString('base64')
|
||||
})
|
||||
}),
|
||||
testEnv
|
||||
)
|
||||
assert.equal(response.status, 200)
|
||||
const card = await response.json()
|
||||
assert.match(card.cardId, /^[0-9a-f-]{36}$/)
|
||||
assert.equal(card.shareUrl, `https://share.example.com/s/${card.cardId}`)
|
||||
|
||||
const page = await worker.fetch(new Request(card.shareUrl), testEnv)
|
||||
assert.equal(page.status, 200)
|
||||
const pageHtml = await page.text()
|
||||
assert.match(pageHtml, /updateAppMessageShareData/)
|
||||
assert.match(pageHtml, new RegExp(`/l/${card.cardId}`))
|
||||
|
||||
const link = await worker.fetch(
|
||||
new Request(`https://share.example.com/l/${card.cardId}`),
|
||||
testEnv
|
||||
)
|
||||
assert.equal(link.status, 302)
|
||||
assert.equal(link.headers.get('location'), `https://share.example.com/v/${card.cardId}`)
|
||||
|
||||
const asset = await worker.fetch(
|
||||
new Request(`https://share.example.com/a/${card.cardId}/thumbnail`),
|
||||
testEnv
|
||||
)
|
||||
assert.equal(asset.status, 200)
|
||||
assert.equal(await asset.text(), 'jpeg-data')
|
||||
})
|
||||
|
||||
test('escapes untrusted card metadata and produces the expected SHA-1', async () => {
|
||||
assert.equal(
|
||||
escapeHtml(`<img src=x onerror="alert('x')">&`),
|
||||
'<img src=x onerror="alert('x')">&'
|
||||
)
|
||||
assert.equal(await sha1('abc'), 'a9993e364706816aba3e25717850c26c9cd0d89d')
|
||||
})
|
||||
|
||||
test('removes expired cards when they are requested', async () => {
|
||||
const testEnv = env()
|
||||
const id = '11111111-1111-4111-8111-111111111111'
|
||||
await testEnv.REPORTS.put(
|
||||
`cards/${id}/card.json`,
|
||||
JSON.stringify({
|
||||
id,
|
||||
title: 'expired',
|
||||
description: '',
|
||||
expiresAt: new Date(Date.now() - 1000).toISOString()
|
||||
})
|
||||
)
|
||||
await testEnv.REPORTS.put(`cards/${id}/report.png`, 'image')
|
||||
await testEnv.REPORTS.put(`cards/${id}/thumbnail.jpg`, 'thumb')
|
||||
|
||||
const response = await worker.fetch(new Request(`https://share.example.com/s/${id}`), testEnv)
|
||||
assert.equal(response.status, 404)
|
||||
assert.equal(testEnv.REPORTS.objects.size, 0)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "wechatexplorer-share-card",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-07-23",
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "share.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "REPORTS",
|
||||
"bucket_name": "wechatexplorer-share-reports"
|
||||
}
|
||||
],
|
||||
"triggers": {
|
||||
"crons": ["17 3 * * *"]
|
||||
},
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://share.example.com",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "wechatexplorer-share-card",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-07-23",
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "share.example.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"vars": {
|
||||
"PUBLIC_ORIGIN": "https://share.example.com",
|
||||
"DEFAULT_EXPIRY_DAYS": "7"
|
||||
}
|
||||
}
|
||||
@@ -928,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 =
|
||||
@@ -1607,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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+149
-6
@@ -16,9 +16,8 @@ import {
|
||||
dialog,
|
||||
protocol
|
||||
} from 'electron'
|
||||
import { dirname, join } from 'path'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { existsSync, promises as fsPromises } from 'fs'
|
||||
import { extname } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { WechatDb } from './wechat-db'
|
||||
@@ -32,14 +31,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,
|
||||
@@ -99,6 +111,15 @@ import {
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
|
||||
import type { PersonalWechatSendRequest } from '../shared/personal-wechat'
|
||||
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
||||
import type {
|
||||
ListTextToSpeechVoicesRequest,
|
||||
SaveTextToSpeechSettingsRequest,
|
||||
SynthesizeTextToSpeechRequest
|
||||
} from '../shared/text-to-speech'
|
||||
import { appLogger } from './app-logger'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { appUpdateService } from './services/app-update-service'
|
||||
@@ -122,6 +143,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
|
||||
@@ -139,8 +166,12 @@ let videoAssetService: VideoAssetService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const aiProviderService = new AIProviderService()
|
||||
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
||||
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
|
||||
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
|
||||
@@ -570,6 +601,11 @@ app.whenReady().then(async () => {
|
||||
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
||||
}
|
||||
})
|
||||
personalWechatRuntimeManager.setProgressListener((status) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wechat-personal:runtimeProgress', status)
|
||||
}
|
||||
})
|
||||
protocol.handle('wxe-media', async (request) => {
|
||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||
if (!filePath) return new Response('Not found', { status: 404 })
|
||||
@@ -1096,6 +1132,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)
|
||||
)
|
||||
@@ -1113,6 +1150,28 @@ app.whenReady().then(async () => {
|
||||
aiProviderService.migrateLegacy(config)
|
||||
)
|
||||
|
||||
ipcMain.handle('tts:getSettings', () => textToSpeechSettingsService.get())
|
||||
ipcMain.handle('tts:saveSettings', (_, request: SaveTextToSpeechSettingsRequest) =>
|
||||
textToSpeechSettingsService.save(request)
|
||||
)
|
||||
ipcMain.handle('tts:listVoices', (_, request?: ListTextToSpeechVoicesRequest) =>
|
||||
textToSpeechSettingsService.listVoices(request)
|
||||
)
|
||||
ipcMain.handle('tts:synthesize', (_, request: SynthesizeTextToSpeechRequest) =>
|
||||
textToSpeechSettingsService.synthesize(request)
|
||||
)
|
||||
ipcMain.handle('tts:removeGeneratedAudio', (_, filePath: string) =>
|
||||
textToSpeechSettingsService.removeGeneratedAudio(String(filePath || ''))
|
||||
)
|
||||
ipcMain.handle('tts:openApiKeys', async () => {
|
||||
try {
|
||||
await shell.openExternal('https://fish.audio/app/api-keys/')
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法打开 Fish Audio API Key 页面' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
|
||||
try {
|
||||
if (typeof imageSource !== 'string' || !imageSource) {
|
||||
@@ -1132,12 +1191,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 +1226,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 +1252,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) => {
|
||||
@@ -1650,6 +1739,59 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
|
||||
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
|
||||
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
||||
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
|
||||
ipcMain.handle('wechat-personal:getRuntimeStatus', () =>
|
||||
personalWechatRuntimeManager.getStatus()
|
||||
)
|
||||
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
||||
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
||||
success: personalWechatRuntimeManager.cancelDownload()
|
||||
}))
|
||||
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
||||
await personalWechatSendService.terminate()
|
||||
return personalWechatRuntimeManager.remove()
|
||||
})
|
||||
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
|
||||
const status = await personalWechatRuntimeManager.getStatus()
|
||||
const directory = status.directory || personalWechatRuntimeManager.directory
|
||||
await fsPromises.mkdir(directory, { recursive: true })
|
||||
const error = await shell.openPath(directory)
|
||||
return error ? { success: false, error } : { success: true }
|
||||
})
|
||||
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
||||
ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) =>
|
||||
personalWechatSendService.send(request)
|
||||
)
|
||||
ipcMain.handle('wechat-personal:selectImage', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
title: '选择要通过个人微信测试发送的图片',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || !result.filePaths[0]) return { canceled: true }
|
||||
return {
|
||||
canceled: false,
|
||||
path: result.filePaths[0],
|
||||
name: basename(result.filePaths[0])
|
||||
}
|
||||
})
|
||||
ipcMain.handle('wechat-personal:selectVoice', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
title: '选择要通过个人微信测试发送的语音',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || !result.filePaths[0]) return { canceled: true }
|
||||
return { canceled: false, path: result.filePaths[0], name: basename(result.filePaths[0]) }
|
||||
})
|
||||
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
@@ -1709,6 +1851,7 @@ app.on('before-quit', (event) => {
|
||||
|
||||
void (async () => {
|
||||
agentHubService.stop()
|
||||
personalWechatSendService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
const [, nativeCallsDrained] = await Promise.all([
|
||||
apiServer.stop().catch(() => undefined),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -683,7 +683,7 @@ export class KeyService {
|
||||
if (loginRequired) {
|
||||
return {
|
||||
success: false,
|
||||
error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。',
|
||||
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
||||
logs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AIProviderSummary,
|
||||
AiSearchProviderStatus,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionRuntimeConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
LegacyAIConfig
|
||||
@@ -74,6 +75,60 @@ export class AIProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
getVisionRuntimeConfig(): AIVisionRuntimeConfig {
|
||||
const result = this.list()
|
||||
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
|
||||
const defaultModel = defaultProvider?.models.find(
|
||||
(item) => item.id === defaultProvider.defaultModel
|
||||
)
|
||||
if (
|
||||
defaultProvider &&
|
||||
defaultModel &&
|
||||
(defaultProvider.hasApiKey || !needsApiKey(defaultProvider)) &&
|
||||
(defaultModel.capabilities.vision || defaultModel.capabilities.ocr)
|
||||
) {
|
||||
return {
|
||||
providerId: defaultProvider.id,
|
||||
providerName: defaultProvider.name,
|
||||
model: defaultModel.id,
|
||||
modelName: defaultModel.name || defaultModel.id,
|
||||
configured: true,
|
||||
status: defaultProvider.status,
|
||||
timeoutMs: defaultProvider.advanced.timeoutMs,
|
||||
source: 'default-model'
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of result.providers) {
|
||||
if (!provider.hasApiKey && needsApiKey(provider)) continue
|
||||
const model =
|
||||
provider.models.find(
|
||||
(item) =>
|
||||
item.id === provider.defaultModel && (item.capabilities.vision || item.capabilities.ocr)
|
||||
) || provider.models.find((item) => item.capabilities.vision || item.capabilities.ocr)
|
||||
if (!model) continue
|
||||
return {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model: model.id,
|
||||
modelName: model.name || model.id,
|
||||
configured: true,
|
||||
status: provider.status,
|
||||
timeoutMs: provider.advanced.timeoutMs,
|
||||
source: 'vision-capability'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providerName: '尚未配置',
|
||||
model: '',
|
||||
modelName: '尚未验证图片理解模型',
|
||||
configured: false,
|
||||
status: 'untested',
|
||||
source: 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus {
|
||||
const result = this.list()
|
||||
const provider =
|
||||
|
||||
@@ -3,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()
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { execFile } from 'child_process'
|
||||
import { app, net } from 'electron'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import type {
|
||||
PersonalWechatRuntimeDownloadResult,
|
||||
PersonalWechatRuntimeStatus
|
||||
} from '../../shared/personal-wechat-runtime'
|
||||
import { findPersonalWechatRuntime } from './personal-wechat-send-service'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const RUNTIME_VERSION = 'v0.0.18'
|
||||
const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz'
|
||||
const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}`
|
||||
const ARCHIVE_SIZE = 66_599_785
|
||||
const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab'
|
||||
|
||||
function patchPerSendPayload(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||
|
||||
const declarations = 'var triggerX1Payload;\nvar triggerX0;'
|
||||
const patchedDeclarations =
|
||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
|
||||
const originalSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
triggerX1Payload.writeByteArray(payloadData);
|
||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||
const patchedSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||
|
||||
if (!source.includes(declarations) || !source.includes(originalSend)) {
|
||||
throw new Error('下载的发送组件与当前应用不兼容')
|
||||
}
|
||||
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
|
||||
source = source.replace(
|
||||
' MMStartTask(triggerX0, triggerX1Payload);',
|
||||
' MMStartTask(triggerX0, activeTriggerX1Payload);'
|
||||
)
|
||||
source = source.replace(
|
||||
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||
)
|
||||
source = source.replace(
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
|
||||
)
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchImageHookReadiness(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (
|
||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||
source.includes('图片上传 Hook Setup Complete')
|
||||
) {
|
||||
return
|
||||
}
|
||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||
\t\t\t}
|
||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容')
|
||||
source = source.replace(original, patched)
|
||||
source = source.replace(
|
||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||
)
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchWechatCoreModuleBase(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('WeChat core module base:')) return
|
||||
const initMarker = 'function initAddresses() {'
|
||||
const initIndex = source.indexOf(initMarker)
|
||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||
throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||
}
|
||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||
var module = Process.enumerateModules().find(function(m) {
|
||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||
});
|
||||
if (!module) {
|
||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||
}
|
||||
var moduleBase = module.base;
|
||||
var baseAddr = moduleBase;
|
||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||
setImmediate(initAddresses);
|
||||
|
||||
`
|
||||
source = patchedHeader + source.slice(initIndex)
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function addModifiedWorkNotice(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||
source = `/*
|
||||
* TraceMemo wechat_chatter compatibility modifications
|
||||
* Modified: 2026-08-17
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* License: GNU General Public License version 3 (GPL-3.0)
|
||||
* Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging.
|
||||
* These modifications are not provided by the upstream author.
|
||||
*/
|
||||
|
||||
${source}`
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
export class PersonalWechatRuntimeManager {
|
||||
private downloadController: AbortController | null = null
|
||||
private downloadPromise: Promise<PersonalWechatRuntimeDownloadResult> | null = null
|
||||
private downloadedBytes = 0
|
||||
private lastProgressAt = 0
|
||||
private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null
|
||||
|
||||
get directory(): string {
|
||||
return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64')
|
||||
}
|
||||
|
||||
private get archivePath(): string {
|
||||
return join(
|
||||
app.getPath('userData'),
|
||||
'downloads',
|
||||
`wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}`
|
||||
)
|
||||
}
|
||||
|
||||
setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void {
|
||||
this.progressListener = listener
|
||||
}
|
||||
|
||||
async getStatus(): Promise<PersonalWechatRuntimeStatus> {
|
||||
if (!this.isSupported()) {
|
||||
return this.buildStatus(
|
||||
'unsupported',
|
||||
0,
|
||||
process.platform === 'win32'
|
||||
? 'Windows 暂不支持个人微信发送组件'
|
||||
: `当前系统暂不支持个人微信发送组件:${process.platform} ${process.arch}`
|
||||
)
|
||||
}
|
||||
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
||||
|
||||
const runtime = findPersonalWechatRuntime()
|
||||
if (runtime) {
|
||||
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
|
||||
}
|
||||
|
||||
const hasPartialInstall = await this.hasPartialInstall()
|
||||
return this.buildStatus(
|
||||
hasPartialInstall ? 'invalid' : 'missing',
|
||||
0,
|
||||
hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined,
|
||||
hasPartialInstall ? this.directory : undefined
|
||||
)
|
||||
}
|
||||
|
||||
download(): Promise<PersonalWechatRuntimeDownloadResult> {
|
||||
if (!this.isSupported()) {
|
||||
return this.getStatus().then((status) => ({ success: false, status, error: status.error }))
|
||||
}
|
||||
if (this.downloadPromise) return this.downloadPromise
|
||||
this.downloadedBytes = 0
|
||||
this.downloadController = new AbortController()
|
||||
this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => {
|
||||
this.downloadPromise = null
|
||||
this.downloadController = null
|
||||
})
|
||||
return this.downloadPromise
|
||||
}
|
||||
|
||||
cancelDownload(): boolean {
|
||||
if (!this.downloadController) return false
|
||||
this.downloadController.abort()
|
||||
return true
|
||||
}
|
||||
|
||||
async remove(): Promise<PersonalWechatRuntimeStatus> {
|
||||
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
||||
await Promise.all([
|
||||
rm(this.directory, { recursive: true, force: true }),
|
||||
rm(this.archivePath, { force: true }),
|
||||
rm(`${this.archivePath}.partial`, { force: true })
|
||||
])
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
private async runDownload(signal: AbortSignal): Promise<PersonalWechatRuntimeDownloadResult> {
|
||||
const archive = this.archivePath
|
||||
const downloadsDirectory = dirname(archive)
|
||||
const partial = `${archive}.partial`
|
||||
let extractionDirectory = ''
|
||||
let stagedDirectory = ''
|
||||
try {
|
||||
await mkdir(downloadsDirectory, { recursive: true })
|
||||
await rm(partial, { force: true })
|
||||
const response = await net.fetch(ARCHIVE_URL, { signal })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`发送组件下载失败:HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const handle = await open(partial, 'w')
|
||||
const hash = createHash('sha256')
|
||||
try {
|
||||
const reader = response.body.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError')
|
||||
const chunk = Buffer.from(value)
|
||||
await handle.write(chunk)
|
||||
hash.update(chunk)
|
||||
this.downloadedBytes += chunk.length
|
||||
this.reportProgress(this.buildStatus('downloading', this.downloadedBytes))
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
|
||||
if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) {
|
||||
throw new Error('发送组件校验失败,请重新下载')
|
||||
}
|
||||
await rm(archive, { force: true })
|
||||
await rename(partial, archive)
|
||||
|
||||
extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-'))
|
||||
await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory])
|
||||
stagedDirectory = `${this.directory}.installing-${process.pid}`
|
||||
await rm(stagedDirectory, { recursive: true, force: true })
|
||||
await mkdir(dirname(stagedDirectory), { recursive: true })
|
||||
await mkdir(join(stagedDirectory, 'onebot'), { recursive: true })
|
||||
|
||||
const sourceOneBot = join(extractionDirectory, 'onebot')
|
||||
const sourceVersions = join(extractionDirectory, 'wechat_version')
|
||||
await Promise.all([
|
||||
cp(sourceVersions, join(stagedDirectory, 'wechat_version'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
}),
|
||||
copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')),
|
||||
copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js'))
|
||||
])
|
||||
|
||||
const executable = join(stagedDirectory, 'onebot', 'onebot')
|
||||
const script = join(stagedDirectory, 'onebot', 'script.js')
|
||||
patchWechatCoreModuleBase(script)
|
||||
patchPerSendPayload(script)
|
||||
patchImageHookReadiness(script)
|
||||
addModifiedWorkNotice(script)
|
||||
await chmod(executable, 0o755)
|
||||
|
||||
const pythonPackages = join(stagedDirectory, 'python')
|
||||
await mkdir(pythonPackages, { recursive: true })
|
||||
try {
|
||||
await execFileAsync('/usr/bin/env', [
|
||||
'python3',
|
||||
'-m',
|
||||
'pip',
|
||||
'install',
|
||||
'--disable-pip-version-check',
|
||||
'--no-compile',
|
||||
'--target',
|
||||
pythonPackages,
|
||||
'pilk==0.2.4'
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[PersonalWechatRuntime] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
executable,
|
||||
script,
|
||||
join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
|
||||
]) {
|
||||
if (!existsSync(required)) throw new Error('发送组件解压后文件不完整')
|
||||
}
|
||||
|
||||
await rm(this.directory, { recursive: true, force: true })
|
||||
await rename(stagedDirectory, this.directory)
|
||||
stagedDirectory = ''
|
||||
const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory)
|
||||
this.reportProgress(status, true)
|
||||
return { success: true, status }
|
||||
} catch (error) {
|
||||
const cancelled = signal.aborted
|
||||
const message = cancelled
|
||||
? '发送组件下载已取消'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
await rm(partial, { force: true })
|
||||
const status = this.buildStatus(
|
||||
cancelled ? 'missing' : 'error',
|
||||
this.downloadedBytes,
|
||||
message
|
||||
)
|
||||
this.reportProgress(status, true)
|
||||
return { success: false, status, error: message }
|
||||
} finally {
|
||||
if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true })
|
||||
if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async hasPartialInstall(): Promise<boolean> {
|
||||
try {
|
||||
await stat(this.directory)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isSupported(): boolean {
|
||||
return process.platform === 'darwin' && process.arch === 'arm64'
|
||||
}
|
||||
|
||||
private buildStatus(
|
||||
state: PersonalWechatRuntimeStatus['state'],
|
||||
downloadedBytes: number,
|
||||
error?: string,
|
||||
directory?: string
|
||||
): PersonalWechatRuntimeStatus {
|
||||
return {
|
||||
version: RUNTIME_VERSION,
|
||||
state,
|
||||
downloadedBytes,
|
||||
totalBytes: ARCHIVE_SIZE,
|
||||
progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
supported: this.isSupported(),
|
||||
removable: directory === this.directory,
|
||||
...(directory ? { directory } : {}),
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void {
|
||||
const now = Date.now()
|
||||
if (!force && now - this.lastProgressAt < 100) return
|
||||
this.lastProgressAt = now
|
||||
this.progressListener?.(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile, spawn, type ChildProcess } from 'child_process'
|
||||
import { createHash } from 'crypto'
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
||||
import { createConnection } from 'net'
|
||||
import { homedir } from 'os'
|
||||
import { delimiter, dirname, join, sep } from 'path'
|
||||
import ffmpegStaticPath from 'ffmpeg-static'
|
||||
import { promisify } from 'util'
|
||||
import type {
|
||||
PersonalWechatSendRequest,
|
||||
PersonalWechatSendResult,
|
||||
PersonalWechatSenderStatus
|
||||
} from '../../shared/personal-wechat'
|
||||
import { isPackagedRuntime } from '../runtime-mode'
|
||||
import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_HOST = '127.0.0.1:58080'
|
||||
const START_TIMEOUT_MS = 20_000
|
||||
const REQUEST_TIMEOUT_MS = 20_000
|
||||
const STOP_TIMEOUT_MS = 3_000
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
const MAX_VOICE_BYTES = 20 * 1024 * 1024
|
||||
const WECHAT_APP_PATH = '/Applications/WeChat.app'
|
||||
const WECHAT_FILES_ROOT = join(
|
||||
homedir(),
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||
)
|
||||
|
||||
export interface RuntimeLayout {
|
||||
root: string
|
||||
executable: string
|
||||
workingDirectory: string
|
||||
configDirectory: string
|
||||
logPath: string
|
||||
}
|
||||
|
||||
export type PersonalWechatHookReadiness = 'unknown' | 'initializing' | 'ready' | 'failed'
|
||||
|
||||
export function parsePersonalWechatHookLog(log: string): {
|
||||
readiness: PersonalWechatHookReadiness
|
||||
attached: boolean
|
||||
baseAddress?: string
|
||||
textHookInstalled: boolean
|
||||
textHookReady: boolean
|
||||
imageHookInstalled: boolean
|
||||
imageHookReady: boolean
|
||||
messageListenerReady: boolean
|
||||
boundWechatPid?: number
|
||||
error?: string
|
||||
} {
|
||||
let readiness: PersonalWechatHookReadiness = 'unknown'
|
||||
let attached = false
|
||||
let baseAddress: string | undefined
|
||||
let textHookInstalled = false
|
||||
let textHookReady = false
|
||||
let imageHookInstalled = false
|
||||
let imageHookReady = false
|
||||
let messageListenerReady = false
|
||||
let boundWechatPid: number | undefined
|
||||
let error: string | undefined
|
||||
for (const line of log.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const entry = JSON.parse(line) as {
|
||||
payload?: string
|
||||
err?: string
|
||||
message?: string
|
||||
PID?: number
|
||||
type?: string
|
||||
result?: string | number
|
||||
}
|
||||
const text = `${entry.payload || ''} ${entry.err || ''} ${entry.message || ''}`
|
||||
// wechat_chatter writes the task receipt and its result as two separate
|
||||
// JSON log records: the first has type=send_image, while the next has
|
||||
// the result and the Chinese result message but no type field.
|
||||
if (
|
||||
(entry.type === 'send_image' || text.includes('发送图片任务执行结果')) &&
|
||||
String(entry.result) === '1'
|
||||
) {
|
||||
imageHookInstalled = true
|
||||
imageHookReady = true
|
||||
}
|
||||
if (
|
||||
(entry.type === 'image' || text.includes('上传图片任务执行结果')) &&
|
||||
String(entry.result) === '0'
|
||||
) {
|
||||
imageHookInstalled = true
|
||||
}
|
||||
if (text.includes('使用指定的微信进程 PID')) {
|
||||
readiness = 'unknown'
|
||||
attached = false
|
||||
baseAddress = undefined
|
||||
textHookInstalled = false
|
||||
textHookReady = false
|
||||
imageHookInstalled = false
|
||||
imageHookReady = false
|
||||
messageListenerReady = false
|
||||
boundWechatPid = Number(entry.PID) || undefined
|
||||
error = undefined
|
||||
} else if (
|
||||
text.includes("Cannot find 'req2buf' keyword") ||
|
||||
text.includes('Attach 失败') ||
|
||||
text.includes('unable to intercept function')
|
||||
) {
|
||||
readiness = 'failed'
|
||||
error = entry.err || entry.message
|
||||
} else if (text.includes('成功 Attach 微信进程')) {
|
||||
attached = true
|
||||
boundWechatPid = Number(entry.PID) || boundWechatPid
|
||||
} else if (text.includes('Base address from range:')) {
|
||||
baseAddress = text.match(/Base address from range:\s*(0x[0-9a-f]+)/i)?.[1]
|
||||
} else if (text.includes('WeChat core module base:')) {
|
||||
baseAddress = text.match(/WeChat core module base:\s*(0x[0-9a-f]+)/i)?.[1]
|
||||
} else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) {
|
||||
readiness = 'initializing'
|
||||
error = '微信底层 Hook 尚未就绪,消息没有发出'
|
||||
} else if (text.includes('捕获到 StartTask 调用')) {
|
||||
readiness = 'ready'
|
||||
textHookReady = true
|
||||
error = undefined
|
||||
} else if (text.includes('Dynamic Text Message Setup Complete')) {
|
||||
textHookInstalled = true
|
||||
if (readiness === 'unknown') readiness = 'initializing'
|
||||
} else if (text.includes('捕获到图片上传上下文')) {
|
||||
imageHookReady = true
|
||||
} else if (text.includes('图片上传 Hook Setup Complete')) {
|
||||
imageHookInstalled = true
|
||||
} else if (text.includes('HTTP 服务启动在')) {
|
||||
messageListenerReady = true
|
||||
} else if (text.includes('发送数据')) {
|
||||
messageListenerReady = true
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON or partially written log lines.
|
||||
}
|
||||
}
|
||||
return {
|
||||
readiness,
|
||||
attached,
|
||||
...(baseAddress ? { baseAddress } : {}),
|
||||
textHookInstalled,
|
||||
textHookReady,
|
||||
imageHookInstalled,
|
||||
imageHookReady,
|
||||
messageListenerReady,
|
||||
...(boundWechatPid ? { boundWechatPid } : {}),
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
interface PreflightResult {
|
||||
status: PersonalWechatSenderStatus
|
||||
runtime?: RuntimeLayout
|
||||
}
|
||||
|
||||
function toConfigFileName(version: string): string {
|
||||
return `${version.replace(/\./g, '_')}_mac.json`
|
||||
}
|
||||
|
||||
function runtimeCandidates(): string[] {
|
||||
const override = String(process.env['WECHAT_CHATTER_RUNTIME_DIR'] || '').trim()
|
||||
const relative = ['connectors', 'wechat-personal', `${process.platform}-${process.arch}`]
|
||||
const downloaded = join(app.getPath('userData'), ...relative)
|
||||
const packaged = join(process.resourcesPath, 'resources', ...relative)
|
||||
const development = join(app.getAppPath(), 'resources', ...relative)
|
||||
const ordered = isPackagedRuntime()
|
||||
? [downloaded, packaged, development]
|
||||
: [downloaded, development, packaged]
|
||||
return override ? [override, ...ordered] : ordered
|
||||
}
|
||||
|
||||
export function findPersonalWechatRuntime(candidates = runtimeCandidates()): RuntimeLayout | null {
|
||||
for (const root of candidates) {
|
||||
const nestedExecutable = join(root, 'onebot', 'onebot')
|
||||
const flatExecutable = join(root, 'onebot')
|
||||
if (existsSync(nestedExecutable) && existsSync(join(root, 'onebot', 'script.js'))) {
|
||||
return {
|
||||
root,
|
||||
executable: nestedExecutable,
|
||||
workingDirectory: join(root, 'onebot'),
|
||||
configDirectory: join(root, 'wechat_version'),
|
||||
logPath: join(root, 'onebot', 'log', 'macos.log')
|
||||
}
|
||||
}
|
||||
if (existsSync(flatExecutable) && existsSync(join(root, 'script.js'))) {
|
||||
return {
|
||||
root,
|
||||
executable: flatExecutable,
|
||||
workingDirectory: root,
|
||||
configDirectory: join(root, 'wechat_version'),
|
||||
logPath: join(root, 'log', 'macos.log')
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildPersonalWechatOneBotRequest(
|
||||
request: PersonalWechatSendRequest,
|
||||
fileBase64?: string
|
||||
): {
|
||||
endpoint: string
|
||||
body: Record<string, unknown>
|
||||
} {
|
||||
const target = request.to.trim()
|
||||
const isGroup = request.isGroup || target.endsWith('@chatroom')
|
||||
const message =
|
||||
request.type === 'text'
|
||||
? [{ type: 'text', data: { text: request.text.trim() } }]
|
||||
: [
|
||||
{
|
||||
type: request.type === 'voice' ? 'record' : 'image',
|
||||
data: { file: `base64://${fileBase64 || ''}` }
|
||||
}
|
||||
]
|
||||
return {
|
||||
endpoint: isGroup ? '/send_group_msg' : '/send_private_msg',
|
||||
body: {
|
||||
...(isGroup ? { group_id: target } : { user_id: target }),
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRuntimePath(): string {
|
||||
const existing = String(process.env['PATH'] || '')
|
||||
const bundledFfmpeg = String(ffmpegStaticPath || '')
|
||||
.replace('app.asar', 'app.asar.unpacked')
|
||||
.trim()
|
||||
if (!bundledFfmpeg || !existsSync(bundledFfmpeg)) return existing
|
||||
return [dirname(bundledFfmpeg), existing].filter(Boolean).join(delimiter)
|
||||
}
|
||||
|
||||
function buildRuntimePythonPath(runtimeRoot: string): string {
|
||||
return [join(runtimeRoot, 'python'), String(process.env['PYTHONPATH'] || '')]
|
||||
.filter(Boolean)
|
||||
.join(delimiter)
|
||||
}
|
||||
|
||||
function createWavBuffer(pcm: Buffer, sampleRate: number, channels: number): Buffer {
|
||||
const header = Buffer.alloc(44)
|
||||
header.write('RIFF', 0)
|
||||
header.writeUInt32LE(36 + pcm.length, 4)
|
||||
header.write('WAVE', 8)
|
||||
header.write('fmt ', 12)
|
||||
header.writeUInt32LE(16, 16)
|
||||
header.writeUInt16LE(1, 20)
|
||||
header.writeUInt16LE(channels, 22)
|
||||
header.writeUInt32LE(sampleRate, 24)
|
||||
header.writeUInt32LE(sampleRate * channels * 2, 28)
|
||||
header.writeUInt16LE(channels * 2, 32)
|
||||
header.writeUInt16LE(16, 34)
|
||||
header.write('data', 36)
|
||||
header.writeUInt32LE(pcm.length, 40)
|
||||
return Buffer.concat([header, pcm])
|
||||
}
|
||||
|
||||
async function prepareVoiceFile(filePath: string): Promise<Buffer> {
|
||||
const data = readFileSync(filePath)
|
||||
if (!data.subarray(0, 10).equals(Buffer.from('\x02#!SILK_V3'))) return data
|
||||
const decoded = await new SilkAudioDecoder().decode({
|
||||
data,
|
||||
codec: 'silk',
|
||||
sourceHash: createHash('sha256').update(data).digest('hex')
|
||||
})
|
||||
return createWavBuffer(decoded.pcm, decoded.sampleRate, decoded.channels)
|
||||
}
|
||||
|
||||
async function readWechatVersion(): Promise<string> {
|
||||
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
|
||||
'-c',
|
||||
'Print :WeChatBundleVersion',
|
||||
join(WECHAT_APP_PATH, 'Contents', 'Info.plist')
|
||||
])
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function readWechatPid(): Promise<number | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat'])
|
||||
const pid = Number(stdout.trim().split(/\s+/)[0])
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Locate the current account's temporary image directory required by the upstream hook. */
|
||||
export function findWechatImagePath(
|
||||
root = WECHAT_FILES_ROOT,
|
||||
now = new Date()
|
||||
): string | undefined {
|
||||
try {
|
||||
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const candidates: Array<{ path: string; mtime: number }> = []
|
||||
const fallbacks: Array<{ path: string; mtime: number }> = []
|
||||
for (const account of readdirSync(root)) {
|
||||
const tempRoot = join(root, account, 'temp')
|
||||
if (!existsSync(tempRoot)) continue
|
||||
fallbacks.push({
|
||||
path: join(tempRoot, 'ImageTemp', month),
|
||||
mtime: statSync(tempRoot).mtimeMs
|
||||
})
|
||||
const imageTempPath = join(tempRoot, 'ImageTemp', month)
|
||||
if (existsSync(imageTempPath)) {
|
||||
candidates.push({ path: imageTempPath, mtime: statSync(imageTempPath).mtimeMs })
|
||||
}
|
||||
for (const tempId of readdirSync(tempRoot)) {
|
||||
const imagePath = join(tempRoot, tempId, month, 'Img')
|
||||
if (existsSync(imagePath)) {
|
||||
candidates.push({ path: imagePath, mtime: statSync(imagePath).mtimeMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => b.mtime - a.mtime)
|
||||
fallbacks.sort((a, b) => b.mtime - a.mtime)
|
||||
const selected = candidates[0]?.path || fallbacks[0]?.path
|
||||
return selected ? `${selected}${sep}` : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function isSipDisabled(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||
return `${stdout}\n${stderr}`.toLowerCase().includes('disabled')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface OneBotProcessInfo {
|
||||
pid: number
|
||||
boundWechatPid?: number
|
||||
imagePath?: string
|
||||
command: string
|
||||
}
|
||||
|
||||
async function readOneBotProcessInfo(): Promise<OneBotProcessInfo | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/sbin/lsof', [
|
||||
'-nP',
|
||||
'-t',
|
||||
`-iTCP:${DEFAULT_HOST.split(':')[1]}`,
|
||||
'-sTCP:LISTEN'
|
||||
])
|
||||
const pid = Number(stdout.trim().split(/\s+/)[0])
|
||||
if (!Number.isInteger(pid) || pid <= 0) return undefined
|
||||
const { stdout: commandOutput } = await execFileAsync('/bin/ps', [
|
||||
'-p',
|
||||
String(pid),
|
||||
'-o',
|
||||
'command='
|
||||
])
|
||||
const command = commandOutput.trim()
|
||||
if (!/(^|\/)onebot(?:\s|$)/.test(command)) return undefined
|
||||
const boundWechatPid = Number(command.match(/-wechat_pid=(\d+)/)?.[1]) || undefined
|
||||
const imagePath = command.match(/-image_path=(\S+)/)?.[1]
|
||||
return {
|
||||
pid,
|
||||
...(boundWechatPid ? { boundWechatPid } : {}),
|
||||
...(imagePath ? { imagePath } : {}),
|
||||
command
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateOneBot(info: OneBotProcessInfo): Promise<void> {
|
||||
if (!/(^|\/)onebot(?:\s|$)/.test(info.command)) return
|
||||
try {
|
||||
process.kill(info.pid, 'SIGTERM')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < STOP_TIMEOUT_MS) {
|
||||
try {
|
||||
process.kill(info.pid, 0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithTimeout(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
timeoutMs = 2_000
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal })
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export class PersonalWechatSendService {
|
||||
private child: ChildProcess | null = null
|
||||
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
|
||||
private lastError = ''
|
||||
|
||||
async getStatus(): Promise<PersonalWechatSenderStatus> {
|
||||
const preflight = await this.preflight()
|
||||
const [endpointReady, oneBot] = await Promise.all([
|
||||
this.isEndpointOnline(),
|
||||
readOneBotProcessInfo()
|
||||
])
|
||||
const hook = this.readHookReadiness(preflight.runtime)
|
||||
const boundWechatPid = oneBot?.boundWechatPid || hook.boundWechatPid
|
||||
const boundToCurrentWechat = Boolean(
|
||||
preflight.status.wechatPid && boundWechatPid === preflight.status.wechatPid
|
||||
)
|
||||
const common = {
|
||||
...preflight.status,
|
||||
endpointReady,
|
||||
...(oneBot?.pid ? { oneBotPid: oneBot.pid } : {}),
|
||||
...(boundWechatPid ? { boundWechatPid } : {}),
|
||||
attachReady: hook.attached,
|
||||
...(hook.baseAddress ? { baseAddress: hook.baseAddress } : {}),
|
||||
baseAddressReady: Boolean(hook.baseAddress),
|
||||
textHookInstalled: hook.textHookInstalled,
|
||||
textHookReady: hook.textHookReady,
|
||||
imageHookInstalled: hook.imageHookInstalled,
|
||||
imageHookReady: hook.imageHookReady,
|
||||
messageListenerReady: hook.messageListenerReady
|
||||
}
|
||||
if (!endpointReady) return common
|
||||
if (!boundToCurrentWechat) {
|
||||
return {
|
||||
...common,
|
||||
state: 'hook_not_ready',
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
message: 'OneBot 仍绑定旧微信进程,请点击“尝试重新绑定”'
|
||||
}
|
||||
}
|
||||
if (hook.readiness === 'failed') {
|
||||
return {
|
||||
...common,
|
||||
state: 'error',
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
message: '微信发送 Hook 初始化失败,请尝试重新绑定',
|
||||
...(hook.error ? { error: hook.error } : {})
|
||||
}
|
||||
}
|
||||
const baseReady = hook.attached && Boolean(hook.baseAddress) && hook.textHookInstalled
|
||||
const canSendText = baseReady && hook.textHookReady
|
||||
const imagePathBound = Boolean(oneBot?.imagePath)
|
||||
const canSendImage = baseReady && hook.imageHookReady && imagePathBound
|
||||
const canSendVoice = baseReady && hook.imageHookReady
|
||||
return {
|
||||
...common,
|
||||
state: canSendText || canSendImage || canSendVoice ? 'online' : 'hook_not_ready',
|
||||
canSend: canSendText || canSendImage || canSendVoice,
|
||||
canSendText,
|
||||
canSendImage,
|
||||
canSendVoice,
|
||||
message:
|
||||
hook.imageHookReady && preflight.status.imagePath && !imagePathBound
|
||||
? 'OneBot 尚未绑定微信图片目录,请点击“尝试重新绑定”'
|
||||
: canSendText || canSendImage || canSendVoice
|
||||
? '个人微信已绑定,可使用已初始化的消息类型'
|
||||
: '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型',
|
||||
...(hook.error ? { error: hook.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async send(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
|
||||
const to = String(request?.to || '').trim()
|
||||
if (!to) {
|
||||
const status = await this.getStatus()
|
||||
return { success: false, status, error: '接收者不能为空' }
|
||||
}
|
||||
let fileBase64: string | undefined
|
||||
if (request.type === 'text') {
|
||||
const text = String(request.text || '').trim()
|
||||
if (!text) {
|
||||
const status = await this.getStatus()
|
||||
return { success: false, status, error: '文字内容不能为空' }
|
||||
}
|
||||
if (text.length > 2_000) {
|
||||
const status = await this.getStatus()
|
||||
return { success: false, status, error: '测试消息不能超过 2000 个字符' }
|
||||
}
|
||||
request = { ...request, to, text }
|
||||
} else {
|
||||
const filePath = String(request.filePath || '').trim()
|
||||
if (!filePath || !existsSync(filePath)) {
|
||||
const status = await this.getStatus()
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
error: `请选择有效的${request.type === 'voice' ? '语音' : '图片'}文件`
|
||||
}
|
||||
}
|
||||
const size = statSync(filePath).size
|
||||
const maxBytes = request.type === 'voice' ? MAX_VOICE_BYTES : MAX_IMAGE_BYTES
|
||||
if (size <= 0 || size > maxBytes) {
|
||||
const status = await this.getStatus()
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
error: `测试${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
||||
}
|
||||
}
|
||||
fileBase64 = (
|
||||
request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath)
|
||||
).toString('base64')
|
||||
request = { ...request, to, filePath }
|
||||
}
|
||||
|
||||
const status = await this.ensureRunning()
|
||||
const typeReady =
|
||||
request.type === 'text'
|
||||
? status.canSendText
|
||||
: request.type === 'voice'
|
||||
? status.canSendVoice
|
||||
: status.canSendImage
|
||||
if (!typeReady) {
|
||||
const guidance =
|
||||
request.type === 'text'
|
||||
? '请先在微信中给任意好友手动发送一条文字,再重新检测'
|
||||
: request.type === 'voice'
|
||||
? '语音复用媒体上传 Hook,请先在微信中手动发送一张普通图片,再重新检测'
|
||||
: '请先在微信中给任意好友手动发送一张普通图片,再重新检测'
|
||||
return { success: false, status, error: status.error || guidance }
|
||||
}
|
||||
|
||||
const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64)
|
||||
try {
|
||||
const response = await requestWithTimeout(
|
||||
`http://${DEFAULT_HOST}${oneBot.endpoint}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(oneBot.body)
|
||||
},
|
||||
request.type === 'voice' ? 60_000 : REQUEST_TIMEOUT_MS
|
||||
)
|
||||
const responseText = await response.text()
|
||||
if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`)
|
||||
const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {}
|
||||
if (parsed.status && parsed.status !== 'ok') throw new Error(responseText)
|
||||
return { success: true, status: await this.getStatus() }
|
||||
} catch (error) {
|
||||
this.lastError = error instanceof Error ? error.message : String(error)
|
||||
const failedStatus = await this.getStatus()
|
||||
return {
|
||||
success: false,
|
||||
status: { ...failedStatus, state: 'error', error: this.lastError },
|
||||
error: `发送失败:${this.lastError}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rebind(): Promise<PersonalWechatSenderStatus> {
|
||||
const preflight = await this.preflight()
|
||||
if (!preflight.runtime || !preflight.status.configPath || !preflight.status.wechatPid) {
|
||||
return preflight.status
|
||||
}
|
||||
const currentStatus = await this.getStatus()
|
||||
const oneBot = await readOneBotProcessInfo()
|
||||
if (
|
||||
oneBot?.boundWechatPid === preflight.status.wechatPid &&
|
||||
currentStatus.attachReady &&
|
||||
currentStatus.baseAddressReady &&
|
||||
currentStatus.textHookInstalled &&
|
||||
currentStatus.imageHookInstalled &&
|
||||
(!preflight.status.imagePath || Boolean(oneBot.imagePath)) &&
|
||||
currentStatus.state !== 'error'
|
||||
) {
|
||||
return {
|
||||
...currentStatus,
|
||||
message: '当前 OneBot 已绑定此微信进程,无需重复注入'
|
||||
}
|
||||
}
|
||||
if (oneBot) await terminateOneBot(oneBot)
|
||||
this.child = null
|
||||
this.startPromise = null
|
||||
this.lastError = ''
|
||||
let status = await this.startRuntime()
|
||||
const retryableHookFailure =
|
||||
status.state === 'error' &&
|
||||
/unable to intercept function|cannot find ['"]req2buf|hook 初始化失败/i.test(
|
||||
`${status.error || ''} ${status.message || ''}`
|
||||
)
|
||||
if (!retryableHookFailure) return status
|
||||
const failedOneBot = await readOneBotProcessInfo()
|
||||
if (failedOneBot) await terminateOneBot(failedOneBot)
|
||||
this.child = null
|
||||
this.lastError = ''
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_500))
|
||||
status = await this.startRuntime()
|
||||
return status
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const child = this.child
|
||||
this.child = null
|
||||
this.startPromise = null
|
||||
// A second Attach to the same WeChat process is unstable. Leave OneBot alive;
|
||||
// it monitors the WeChat PID and exits when that process ends.
|
||||
if (child && child.exitCode === null) child.unref()
|
||||
}
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
const oneBot = await readOneBotProcessInfo()
|
||||
if (oneBot) await terminateOneBot(oneBot)
|
||||
this.child = null
|
||||
this.startPromise = null
|
||||
this.lastError = ''
|
||||
}
|
||||
|
||||
private async ensureRunning(): Promise<PersonalWechatSenderStatus> {
|
||||
const currentStatus = await this.getStatus()
|
||||
if (currentStatus.state === 'online' || currentStatus.state === 'hook_not_ready') {
|
||||
return currentStatus
|
||||
}
|
||||
if (this.startPromise) return this.startPromise
|
||||
this.startPromise = this.startRuntime().finally(() => {
|
||||
this.startPromise = null
|
||||
})
|
||||
return this.startPromise
|
||||
}
|
||||
|
||||
private async startRuntime(): Promise<PersonalWechatSenderStatus> {
|
||||
const preflight = await this.preflight()
|
||||
if (!preflight.runtime || !preflight.status.configPath) {
|
||||
return preflight.status
|
||||
}
|
||||
|
||||
const pid = preflight.status.wechatPid
|
||||
const imagePath = findWechatImagePath()
|
||||
this.lastError = ''
|
||||
const child = spawn(
|
||||
preflight.runtime.executable,
|
||||
[
|
||||
'-type=local',
|
||||
`-receive_host=${DEFAULT_HOST}`,
|
||||
`-wechat_conf=${preflight.status.configPath}`,
|
||||
`-wechat_pid=${pid}`,
|
||||
...(imagePath ? [`-image_path=${imagePath}`] : []),
|
||||
'-send_interval=1000',
|
||||
'-log_level=info'
|
||||
],
|
||||
{
|
||||
cwd: preflight.runtime.workingDirectory,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: buildRuntimePath(),
|
||||
PYTHONPATH: buildRuntimePythonPath(preflight.runtime.root)
|
||||
}
|
||||
}
|
||||
)
|
||||
this.child = child
|
||||
child.unref()
|
||||
child.once('error', (error) => {
|
||||
this.lastError = error.message
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
if (this.child === child) this.child = null
|
||||
if (code && !this.lastError) this.lastError = `发送服务退出(code=${code})`
|
||||
})
|
||||
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < START_TIMEOUT_MS) {
|
||||
const status = await this.getStatus()
|
||||
if (status.state === 'online') return status
|
||||
if (status.state === 'hook_not_ready' && status.attachReady && status.baseAddressReady) {
|
||||
return status
|
||||
}
|
||||
if (status.state === 'error') return status
|
||||
if (this.child?.exitCode !== null && this.child?.exitCode !== undefined) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
const status = await this.preflight()
|
||||
return {
|
||||
...status.status,
|
||||
state: 'error',
|
||||
canSend: false,
|
||||
message: '个人微信发送服务启动失败',
|
||||
error: this.lastError || '启动超时,请查看应用日志'
|
||||
}
|
||||
}
|
||||
|
||||
private async preflight(): Promise<PreflightResult> {
|
||||
const base = {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
sipDisabled: false,
|
||||
wechatRunning: false,
|
||||
runtimeReady: false,
|
||||
endpoint: DEFAULT_HOST,
|
||||
endpointReady: false,
|
||||
attachReady: false,
|
||||
baseAddressReady: false,
|
||||
textHookInstalled: false,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: false,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false
|
||||
}
|
||||
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
||||
return {
|
||||
status: {
|
||||
...base,
|
||||
state: 'unsupported_platform',
|
||||
message: '个人微信测试发送当前仅支持 Apple Silicon Mac'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [sipDisabled, wechatPid] = await Promise.all([isSipDisabled(), readWechatPid()])
|
||||
if (!wechatPid) {
|
||||
return {
|
||||
status: {
|
||||
...base,
|
||||
sipDisabled,
|
||||
state: 'wechat_not_running',
|
||||
message: '请先启动并登录 macOS 微信'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sipDisabled) {
|
||||
return {
|
||||
status: {
|
||||
...base,
|
||||
wechatRunning: true,
|
||||
wechatPid,
|
||||
state: 'sip_enabled',
|
||||
message: '当前 SIP 未关闭,无法直接连接微信进程'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let wechatVersion = ''
|
||||
try {
|
||||
wechatVersion = await readWechatVersion()
|
||||
} catch (error) {
|
||||
return {
|
||||
status: {
|
||||
...base,
|
||||
sipDisabled,
|
||||
wechatRunning: true,
|
||||
wechatPid,
|
||||
state: 'error',
|
||||
message: '无法读取微信精确版本',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = findPersonalWechatRuntime()
|
||||
if (!runtime) {
|
||||
return {
|
||||
status: {
|
||||
...base,
|
||||
sipDisabled,
|
||||
wechatRunning: true,
|
||||
wechatPid,
|
||||
wechatVersion,
|
||||
state: 'runtime_missing',
|
||||
message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 文字转语音”下载'
|
||||
}
|
||||
}
|
||||
}
|
||||
const configPath = join(runtime.configDirectory, toConfigFileName(wechatVersion))
|
||||
if (!existsSync(configPath)) {
|
||||
return {
|
||||
runtime,
|
||||
status: {
|
||||
...base,
|
||||
sipDisabled,
|
||||
wechatRunning: true,
|
||||
wechatPid,
|
||||
wechatVersion,
|
||||
runtimeReady: true,
|
||||
executablePath: runtime.executable,
|
||||
state: 'unsupported_version',
|
||||
message: `当前微信版本 ${wechatVersion} 暂不支持,请前往文字转语音设置查看支持的版本`,
|
||||
error: `缺少 ${configPath}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagePath = findWechatImagePath()
|
||||
return {
|
||||
runtime,
|
||||
status: {
|
||||
...base,
|
||||
sipDisabled,
|
||||
wechatRunning: true,
|
||||
wechatPid,
|
||||
wechatVersion,
|
||||
runtimeReady: true,
|
||||
executablePath: runtime.executable,
|
||||
configPath,
|
||||
...(imagePath ? { imagePath } : {}),
|
||||
state: this.child && this.child.exitCode === null ? 'starting' : 'stopped',
|
||||
message: this.lastError || '尚未绑定当前微信,可点击“尝试重新绑定”'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isEndpointOnline(): Promise<boolean> {
|
||||
try {
|
||||
const [host, portText] = DEFAULT_HOST.split(':')
|
||||
const port = Number(portText)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = createConnection({ host, port })
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy()
|
||||
reject(new Error('timeout'))
|
||||
}, 800)
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
resolve()
|
||||
})
|
||||
socket.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private readHookReadiness(
|
||||
runtime?: RuntimeLayout
|
||||
): ReturnType<typeof parsePersonalWechatHookLog> {
|
||||
if (!runtime || !existsSync(runtime.logPath)) {
|
||||
return {
|
||||
readiness: 'unknown',
|
||||
attached: false,
|
||||
textHookInstalled: false,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: false,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: false
|
||||
}
|
||||
}
|
||||
try {
|
||||
return parsePersonalWechatHookLog(readFileSync(runtime.logPath, 'utf8'))
|
||||
} catch {
|
||||
return {
|
||||
readiness: 'unknown',
|
||||
attached: false,
|
||||
textHookInstalled: false,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: false,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const personalWechatSendService = new PersonalWechatSendService()
|
||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import type { TextToSpeechModel } from '../../shared/text-to-speech'
|
||||
|
||||
/**
|
||||
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
|
||||
@@ -36,6 +37,8 @@ export interface AppSettings {
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: TextToSpeechModel
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
@@ -135,7 +138,9 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
autoLoginPreferenceSet: false,
|
||||
appearanceTheme: 'system',
|
||||
compactMode: false,
|
||||
showStartupProgress: true
|
||||
showStartupProgress: true,
|
||||
ttsSelectedVoiceId: '',
|
||||
ttsModel: 's2.1-pro-free'
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = path.join(
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type {
|
||||
ListTextToSpeechVoicesRequest,
|
||||
ListTextToSpeechVoicesResult,
|
||||
SaveTextToSpeechSettingsRequest,
|
||||
SynthesizeTextToSpeechRequest,
|
||||
SynthesizeTextToSpeechResult,
|
||||
TextToSpeechKeySource,
|
||||
TextToSpeechModel,
|
||||
TextToSpeechSettingsResult,
|
||||
TextToSpeechVoice
|
||||
} from '../../shared/text-to-speech'
|
||||
import { AIProviderKeyStore } from '../ai-provider-key-store'
|
||||
import { loadSettings, updateSettings } from './settings-store'
|
||||
|
||||
const FISH_AUDIO_KEY_ID = 'fish-audio-tts'
|
||||
const FISH_AUDIO_BASE_URL = 'https://api.fish.audio'
|
||||
const FISH_AUDIO_PUBLIC_ASSET_URL = 'https://public-platform.r2.fish.audio/'
|
||||
const DEFAULT_PAGE_SIZE = 24
|
||||
const MAX_PAGE_SIZE = 100
|
||||
const GENERATED_AUDIO_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
interface FishAudioModelEntity {
|
||||
_id: string
|
||||
type: 'svc' | 'tts'
|
||||
title: string
|
||||
description?: string
|
||||
cover_image?: string
|
||||
state: 'created' | 'training' | 'trained' | 'failed'
|
||||
tags?: string[]
|
||||
languages?: string[]
|
||||
default_text?: string
|
||||
samples?: Array<{ title: string; text: string; task_id: string; audio: string }>
|
||||
task_count?: number
|
||||
like_count?: number
|
||||
mark_count?: number
|
||||
author?: { _id: string; nickname: string; avatar: string }
|
||||
}
|
||||
|
||||
interface FishAudioModelListResponse {
|
||||
total: number
|
||||
items: FishAudioModelEntity[]
|
||||
has_more?: boolean | null
|
||||
}
|
||||
|
||||
export class TextToSpeechSettingsService {
|
||||
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||
|
||||
get(): TextToSpeechSettingsResult {
|
||||
const resolved = this.resolveKey()
|
||||
const settings = loadSettings()
|
||||
return {
|
||||
success: resolved.success,
|
||||
settings: {
|
||||
provider: 'fish-audio',
|
||||
hasApiKey: Boolean(resolved.key),
|
||||
hasStoredApiKey: resolved.hasStoredApiKey,
|
||||
hasEnvironmentApiKey: resolved.hasEnvironmentApiKey,
|
||||
keySource: resolved.source,
|
||||
encryptionAvailable: resolved.encryptionAvailable,
|
||||
selectedVoiceId: normalizeSelectedVoiceId(settings.ttsSelectedVoiceId),
|
||||
outputFormat: 'mp3',
|
||||
model: normalizeModel(settings.ttsModel),
|
||||
phase: 'ready'
|
||||
},
|
||||
voices: [],
|
||||
error: resolved.error
|
||||
}
|
||||
}
|
||||
|
||||
save(request: SaveTextToSpeechSettingsRequest): TextToSpeechSettingsResult {
|
||||
const apiKey = request.apiKey?.trim()
|
||||
if (request.clearApiKey) {
|
||||
const cleared = this.keyStore.clear(FISH_AUDIO_KEY_ID)
|
||||
if (!cleared.success) return { ...this.get(), success: false, error: cleared.error }
|
||||
} else if (apiKey) {
|
||||
const saved = this.keyStore.save(FISH_AUDIO_KEY_ID, apiKey)
|
||||
if (!saved.success) return { ...this.get(), success: false, error: saved.error }
|
||||
}
|
||||
|
||||
const patch: { ttsSelectedVoiceId?: string; ttsModel?: TextToSpeechModel } = {}
|
||||
if (request.selectedVoiceId !== undefined) {
|
||||
patch.ttsSelectedVoiceId = request.selectedVoiceId.trim()
|
||||
}
|
||||
if (request.model !== undefined) patch.ttsModel = normalizeModel(request.model)
|
||||
if (Object.keys(patch).length) updateSettings(patch)
|
||||
return this.get()
|
||||
}
|
||||
|
||||
async listVoices(
|
||||
request: ListTextToSpeechVoicesRequest = {}
|
||||
): Promise<ListTextToSpeechVoicesResult> {
|
||||
const pageNumber = clampInteger(request.pageNumber, 1, Number.MAX_SAFE_INTEGER, 1)
|
||||
const pageSize = clampInteger(request.pageSize, 1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE)
|
||||
const resolved = this.resolveKey()
|
||||
if (!resolved.key) {
|
||||
return {
|
||||
success: false,
|
||||
items: [],
|
||||
total: 0,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
hasMore: false,
|
||||
error: resolved.error || '请先配置语音服务 API Key'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL('/model', FISH_AUDIO_BASE_URL)
|
||||
url.searchParams.set('page_size', String(pageSize))
|
||||
url.searchParams.set('page_number', String(pageNumber))
|
||||
url.searchParams.set('sort_by', 'score')
|
||||
if (request.title?.trim()) url.searchParams.set('title', request.title.trim())
|
||||
if (request.language?.trim()) url.searchParams.set('language', request.language.trim())
|
||||
for (const tag of request.tags || []) {
|
||||
if (tag.trim()) url.searchParams.append('tag', tag.trim())
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${resolved.key}` },
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
})
|
||||
if (!response.ok) throw await fishAudioError(response)
|
||||
const payload = (await response.json()) as FishAudioModelListResponse
|
||||
let items = payload.items
|
||||
.filter((model) => model.type === 'tts' && model.state === 'trained')
|
||||
.map(toVoice)
|
||||
|
||||
const selectedVoiceId = normalizeSelectedVoiceId(loadSettings().ttsSelectedVoiceId)
|
||||
if (
|
||||
pageNumber === 1 &&
|
||||
!request.title?.trim() &&
|
||||
!(request.tags || []).length &&
|
||||
selectedVoiceId
|
||||
) {
|
||||
if (!items.some((item) => item.id === selectedVoiceId)) {
|
||||
const selected = await this.getVoice(selectedVoiceId, resolved.key)
|
||||
if (selected) items = [selected, ...items]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
items,
|
||||
total: payload.total,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
hasMore: payload.has_more ?? pageNumber * pageSize < payload.total
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
items: [],
|
||||
total: 0,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
hasMore: false,
|
||||
error: safeFishAudioError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async synthesize(request: SynthesizeTextToSpeechRequest): Promise<SynthesizeTextToSpeechResult> {
|
||||
const text = request.text.trim()
|
||||
const referenceId = request.referenceId.trim()
|
||||
if (!text) return { success: false, error: '请输入要生成语音的文字' }
|
||||
if (text.length > 1000) return { success: false, error: '单次生成文字不能超过 1000 个字符' }
|
||||
if (!referenceId) return { success: false, error: '请先选择音色' }
|
||||
|
||||
const resolved = this.resolveKey()
|
||||
if (!resolved.key) {
|
||||
return {
|
||||
success: false,
|
||||
error: resolved.error || '请先配置语音服务 API Key'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${FISH_AUDIO_BASE_URL}/v1/tts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${resolved.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
model: normalizeModel(loadSettings().ttsModel)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
reference_id: referenceId,
|
||||
format: 'mp3',
|
||||
mp3_bitrate: 128,
|
||||
latency: 'normal',
|
||||
normalize: true
|
||||
}),
|
||||
signal: AbortSignal.timeout(120_000)
|
||||
})
|
||||
if (!response.ok) throw await fishAudioError(response)
|
||||
const audio = Buffer.from(await response.arrayBuffer())
|
||||
if (audio.length < 128) throw new Error('语音服务返回的音频为空')
|
||||
|
||||
const directory = this.generatedAudioDirectory()
|
||||
fs.ensureDirSync(directory)
|
||||
this.cleanupGeneratedAudio(directory)
|
||||
const filePath = path.join(directory, `fish-audio-${Date.now()}-${randomUUID()}.mp3`)
|
||||
fs.writeFileSync(filePath, audio, { mode: 0o600 })
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
audioDataUrl: `data:audio/mpeg;base64,${audio.toString('base64')}`
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: safeFishAudioError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
removeGeneratedAudio(filePath: string): { success: boolean; error?: string } {
|
||||
const directory = this.generatedAudioDirectory()
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
if (!resolvedPath.startsWith(`${path.resolve(directory)}${path.sep}`)) {
|
||||
return { success: false, error: '拒绝删除非 Fish Audio 临时文件' }
|
||||
}
|
||||
try {
|
||||
fs.removeSync(resolvedPath)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '临时语音文件清理失败' }
|
||||
}
|
||||
}
|
||||
|
||||
private resolveKey(): {
|
||||
success: boolean
|
||||
key?: string
|
||||
source: TextToSpeechKeySource
|
||||
hasStoredApiKey: boolean
|
||||
hasEnvironmentApiKey: boolean
|
||||
encryptionAvailable: boolean
|
||||
error?: string
|
||||
} {
|
||||
const stored = this.keyStore.get(FISH_AUDIO_KEY_ID)
|
||||
const environmentKey = String(process.env.FISH_API_KEY || '').trim()
|
||||
const storedKey = stored.key?.trim()
|
||||
const key = storedKey || environmentKey || undefined
|
||||
return {
|
||||
success: stored.success || Boolean(environmentKey),
|
||||
key,
|
||||
source: storedKey ? 'secure-storage' : environmentKey ? 'environment' : 'missing',
|
||||
hasStoredApiKey: Boolean(storedKey),
|
||||
hasEnvironmentApiKey: Boolean(environmentKey),
|
||||
encryptionAvailable: stored.available,
|
||||
error: key ? undefined : stored.error
|
||||
}
|
||||
}
|
||||
|
||||
private async getVoice(id: string, key: string): Promise<TextToSpeechVoice | null> {
|
||||
try {
|
||||
const response = await fetch(`${FISH_AUDIO_BASE_URL}/model/${encodeURIComponent(id)}`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
signal: AbortSignal.timeout(15_000)
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const model = (await response.json()) as FishAudioModelEntity
|
||||
return model.type === 'tts' && model.state === 'trained' ? toVoice(model) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private generatedAudioDirectory(): string {
|
||||
return path.join(app.getPath('temp'), 'wechatexplorer-fish-audio')
|
||||
}
|
||||
|
||||
private cleanupGeneratedAudio(directory: string): void {
|
||||
try {
|
||||
const now = Date.now()
|
||||
for (const name of fs.readdirSync(directory)) {
|
||||
const candidate = path.join(directory, name)
|
||||
if (!name.startsWith('fish-audio-') || !name.endsWith('.mp3')) continue
|
||||
if (now - fs.statSync(candidate).mtimeMs > GENERATED_AUDIO_MAX_AGE_MS)
|
||||
fs.removeSync(candidate)
|
||||
}
|
||||
} catch {
|
||||
// 临时文件清理失败不应阻断当前语音生成。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toVoice(model: FishAudioModelEntity): TextToSpeechVoice {
|
||||
const sample = model.samples?.find((item) => item.audio) || model.samples?.[0]
|
||||
return {
|
||||
id: model._id,
|
||||
name: model.title || '未命名音色',
|
||||
description: model.description || sample?.text || '公开音色',
|
||||
tags: Array.isArray(model.tags) ? model.tags : [],
|
||||
languages: Array.isArray(model.languages) ? model.languages : [],
|
||||
source: 'fish-audio',
|
||||
coverImage: resolvePublicAssetUrl(model.cover_image || model.author?.avatar),
|
||||
previewUrl: sample?.audio || undefined,
|
||||
previewText: sample?.text || model.default_text || undefined,
|
||||
authorName: model.author?.nickname || undefined,
|
||||
taskCount: model.task_count,
|
||||
likeCount: model.like_count,
|
||||
markCount: model.mark_count
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePublicAssetUrl(value?: string): string | undefined {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return undefined
|
||||
if (/^https?:\/\//i.test(normalized)) return normalized
|
||||
if (normalized.startsWith('//')) return `https:${normalized}`
|
||||
return new URL(normalized.replace(/^\/+/, ''), FISH_AUDIO_PUBLIC_ASSET_URL).toString()
|
||||
}
|
||||
|
||||
function normalizeSelectedVoiceId(value: string): string {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized.startsWith('demo-') ? '' : normalized
|
||||
}
|
||||
|
||||
function normalizeModel(value: unknown): TextToSpeechModel {
|
||||
return value === 's2.1-pro' ? 's2.1-pro' : 's2.1-pro-free'
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, Math.floor(parsed)))
|
||||
}
|
||||
|
||||
async function fishAudioError(response: Response): Promise<Error> {
|
||||
let detail = ''
|
||||
try {
|
||||
const payload = (await response.json()) as
|
||||
| { status?: number; message?: string }
|
||||
| Array<{ msg?: string }>
|
||||
detail = Array.isArray(payload)
|
||||
? payload
|
||||
.map((item) => item.msg)
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
: payload.message || ''
|
||||
} catch {
|
||||
detail = await response.text().catch(() => '')
|
||||
}
|
||||
if (response.status === 401) return new Error('API Key 无效或已失效')
|
||||
if (response.status === 402) return new Error('语音服务余额不足')
|
||||
if (response.status === 404) return new Error('所选音色不存在或不可访问')
|
||||
if (response.status === 422) return new Error(detail || '语音生成参数不正确')
|
||||
if (response.status === 503) return new Error('语音服务暂时不可用,请稍后重试')
|
||||
return new Error(detail || `语音服务请求失败(HTTP ${response.status})`)
|
||||
}
|
||||
|
||||
function safeFishAudioError(error: unknown): string {
|
||||
if (error instanceof DOMException && error.name === 'TimeoutError') return '语音服务请求超时'
|
||||
if (error instanceof Error) return error.message
|
||||
return '语音服务请求失败'
|
||||
}
|
||||
@@ -74,12 +74,12 @@ export class SilkAudioDecoder implements VoiceAudioDecoder {
|
||||
decode?: (data: Buffer, sampleRate: number) => Promise<{ data: Uint8Array }>
|
||||
}
|
||||
if (!silkWasm.decode) throw new Error('silk-wasm 运行时无效')
|
||||
const result = await silkWasm.decode(source.data, 24000)
|
||||
const result = await silkWasm.decode(source.data, 16000)
|
||||
const pcm = Buffer.from(result.data)
|
||||
if (!pcm.length) throw new Error('Silk 解码结果为空')
|
||||
return {
|
||||
pcm,
|
||||
sampleRate: 24000,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
sourceHash: source.sourceHash
|
||||
}
|
||||
|
||||
@@ -62,7 +62,11 @@ export class VoiceService {
|
||||
if (!pcmResult.success) return pcmResult
|
||||
const pcmData = pcmResult.audio.pcm
|
||||
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
const wavData = this.createWavBuffer(
|
||||
pcmData,
|
||||
pcmResult.audio.sampleRate,
|
||||
pcmResult.audio.channels
|
||||
)
|
||||
console.log(
|
||||
'[VoiceService] wavData length:',
|
||||
wavData.length,
|
||||
|
||||
+5
-11
@@ -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}`
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { nativeImage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import QRCode from 'qrcode'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
PublishWechatShareCardResult
|
||||
} from '../shared/wechat-share-card'
|
||||
import { WechatShareConfigStore } from './wechat-share-config-store'
|
||||
|
||||
const MAX_REPORT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
export class WechatShareCardService {
|
||||
constructor(private readonly configStore: WechatShareConfigStore) {}
|
||||
|
||||
async publish(request: PublishWechatShareCardRequest): Promise<PublishWechatShareCardResult> {
|
||||
try {
|
||||
const config = await this.configStore.loadRaw()
|
||||
if (!config) return { success: false, error: '请先配置微信卡片服务' }
|
||||
const png = await fs.readFile(request.pngPath)
|
||||
if (!png.length) return { success: false, error: '日报图片为空' }
|
||||
if (png.length > MAX_REPORT_BYTES) return { success: false, error: '日报图片不能超过 25 MB' }
|
||||
|
||||
const source = nativeImage.createFromBuffer(png)
|
||||
if (source.isEmpty()) return { success: false, error: '无法读取日报图片' }
|
||||
const size = source.getSize()
|
||||
const squareSize = Math.min(size.width, size.height)
|
||||
const thumbnail = source
|
||||
.crop({ x: 0, y: 0, width: squareSize, height: squareSize })
|
||||
.resize({ width: 360, height: 360, quality: 'best' })
|
||||
.toJPEG(84)
|
||||
|
||||
const response = await fetch(`${config.serviceUrl}/api/cards`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.uploadToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: request.title.trim().slice(0, 64),
|
||||
description: request.description.trim().slice(0, 120),
|
||||
expiresInDays: Math.max(1, Math.min(30, request.expiresInDays || 7)),
|
||||
imageBase64: png.toString('base64'),
|
||||
thumbnailBase64: thumbnail.toString('base64')
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000)
|
||||
})
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
cardId?: string
|
||||
shareUrl?: string
|
||||
viewUrl?: string
|
||||
expiresAt?: string
|
||||
error?: string
|
||||
}
|
||||
if (!response.ok || !payload.shareUrl) {
|
||||
return { success: false, error: payload.error || `卡片服务返回 HTTP ${response.status}` }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
cardId: payload.cardId,
|
||||
shareUrl: payload.shareUrl,
|
||||
viewUrl: payload.viewUrl,
|
||||
expiresAt: payload.expiresAt,
|
||||
qrCodeDataUrl: await QRCode.toDataURL(payload.shareUrl, {
|
||||
width: 360,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
WechatShareServiceConfig,
|
||||
WechatShareServiceConfigResult
|
||||
} from '../shared/wechat-share-card'
|
||||
|
||||
const normalizeUrl = (value: string): string => value.trim().replace(/\/+$/, '')
|
||||
|
||||
const validate = (config: WechatShareServiceConfig): string | null => {
|
||||
try {
|
||||
const url = new URL(normalizeUrl(config.serviceUrl))
|
||||
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
return '卡片服务必须使用 HTTPS'
|
||||
}
|
||||
} catch {
|
||||
return '卡片服务地址无效'
|
||||
}
|
||||
if (config.uploadToken.trim().length < 24) return '上传密钥至少需要 24 个字符'
|
||||
return null
|
||||
}
|
||||
|
||||
export class WechatShareConfigStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-share-service.bin')
|
||||
}
|
||||
|
||||
async loadRaw(): Promise<WechatShareServiceConfig | null> {
|
||||
if (!(await fs.pathExists(this.filePath))) return null
|
||||
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const parsed = JSON.parse(safeStorage.decryptString(encrypted)) as WechatShareServiceConfig
|
||||
const error = validate(parsed)
|
||||
if (error) throw new Error(error)
|
||||
return { serviceUrl: normalizeUrl(parsed.serviceUrl), uploadToken: parsed.uploadToken.trim() }
|
||||
}
|
||||
|
||||
async status(): Promise<WechatShareServiceConfigResult> {
|
||||
try {
|
||||
const config = await this.loadRaw()
|
||||
return {
|
||||
success: true,
|
||||
configured: Boolean(config),
|
||||
serviceUrl: config?.serviceUrl
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async save(config: WechatShareServiceConfig): Promise<WechatShareServiceConfigResult> {
|
||||
const normalized = {
|
||||
serviceUrl: normalizeUrl(config.serviceUrl),
|
||||
uploadToken: config.uploadToken.trim()
|
||||
}
|
||||
const error = validate(normalized)
|
||||
if (error) return { success: false, error }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(JSON.stringify(normalized)), {
|
||||
mode: 0o600
|
||||
})
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
return { success: true, configured: true, serviceUrl: normalized.serviceUrl }
|
||||
}
|
||||
}
|
||||
Vendored
+89
-2
@@ -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
|
||||
@@ -45,6 +53,18 @@ import type {
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
import type {
|
||||
PersonalWechatImageSelectionResult,
|
||||
PersonalWechatVoiceSelectionResult,
|
||||
PersonalWechatSendRequest,
|
||||
PersonalWechatSendResult,
|
||||
PersonalWechatSenderStatus
|
||||
} from '../shared/personal-wechat'
|
||||
import type {
|
||||
PersonalWechatRuntimeDownloadResult,
|
||||
PersonalWechatRuntimeProgressEvent,
|
||||
PersonalWechatRuntimeStatus
|
||||
} from '../shared/personal-wechat-runtime'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
|
||||
import type { CacheSummary } from '../shared/cache'
|
||||
@@ -72,6 +92,20 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
PublishWechatShareCardResult,
|
||||
WechatShareServiceConfig,
|
||||
WechatShareServiceConfigResult
|
||||
} from '../shared/wechat-share-card'
|
||||
import type {
|
||||
ListTextToSpeechVoicesRequest,
|
||||
ListTextToSpeechVoicesResult,
|
||||
SaveTextToSpeechSettingsRequest,
|
||||
SynthesizeTextToSpeechRequest,
|
||||
SynthesizeTextToSpeechResult,
|
||||
TextToSpeechSettingsResult
|
||||
} from '../shared/text-to-speech'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
@@ -225,6 +259,7 @@ declare global {
|
||||
}>
|
||||
listAIProviders: () => Promise<AIProviderListResult>
|
||||
getAIRuntimeConfig: () => Promise<AIRuntimeModelConfig>
|
||||
getAIVisionRuntimeConfig: () => Promise<AIVisionRuntimeConfig>
|
||||
getAiSearchProviderStatus: () => Promise<AiSearchProviderStatus>
|
||||
authorizeAiSearchExternalProvider: (
|
||||
request: AiSearchExternalAuthorizationRequest
|
||||
@@ -235,6 +270,20 @@ declare global {
|
||||
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
|
||||
testAIVision: (request: AIVisionTestRequest) => Promise<AIVisionTestResult>
|
||||
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
|
||||
getTextToSpeechSettings: () => Promise<TextToSpeechSettingsResult>
|
||||
saveTextToSpeechSettings: (
|
||||
request: SaveTextToSpeechSettingsRequest
|
||||
) => Promise<TextToSpeechSettingsResult>
|
||||
listTextToSpeechVoices: (
|
||||
request?: ListTextToSpeechVoicesRequest
|
||||
) => Promise<ListTextToSpeechVoicesResult>
|
||||
synthesizeTextToSpeech: (
|
||||
request: SynthesizeTextToSpeechRequest
|
||||
) => Promise<SynthesizeTextToSpeechResult>
|
||||
removeGeneratedTextToSpeechAudio: (
|
||||
filePath: string
|
||||
) => Promise<{ success: boolean; error?: string }>
|
||||
openFishAudioApiKeys: () => Promise<{ success: boolean; error?: string }>
|
||||
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
|
||||
getVoiceData: (
|
||||
sessionId: string,
|
||||
@@ -301,16 +350,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<{
|
||||
@@ -351,6 +417,8 @@ declare global {
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -388,6 +456,8 @@ declare global {
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -408,6 +478,8 @@ declare global {
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}>
|
||||
@@ -501,6 +573,21 @@ declare global {
|
||||
sessionId: string,
|
||||
limit?: number
|
||||
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
||||
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
||||
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
||||
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
||||
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
||||
removePersonalWechatRuntime: () => Promise<PersonalWechatRuntimeStatus>
|
||||
openPersonalWechatRuntimeDirectory: () => Promise<{ success: boolean; error?: string }>
|
||||
onPersonalWechatRuntimeProgress: (
|
||||
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
||||
) => () => void
|
||||
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
||||
selectPersonalWechatImage: () => Promise<PersonalWechatImageSelectionResult>
|
||||
selectPersonalWechatVoice: () => Promise<PersonalWechatVoiceSelectionResult>
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
) => Promise<PersonalWechatSendResult>
|
||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||
clearAgentHubLogs: () => Promise<void>
|
||||
|
||||
+83
-2
@@ -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,
|
||||
@@ -19,6 +25,18 @@ import type {
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
import type {
|
||||
PersonalWechatImageSelectionResult,
|
||||
PersonalWechatVoiceSelectionResult,
|
||||
PersonalWechatSendRequest,
|
||||
PersonalWechatSendResult,
|
||||
PersonalWechatSenderStatus
|
||||
} from '../shared/personal-wechat'
|
||||
import type {
|
||||
PersonalWechatRuntimeDownloadResult,
|
||||
PersonalWechatRuntimeProgressEvent,
|
||||
PersonalWechatRuntimeStatus
|
||||
} from '../shared/personal-wechat-runtime'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { AppUpdateState } from '../shared/app-update'
|
||||
import type { CacheSummary } from '../shared/cache'
|
||||
@@ -48,6 +66,15 @@ import type {
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult
|
||||
} from '../shared/knowledge'
|
||||
import type {
|
||||
PublishWechatShareCardRequest,
|
||||
WechatShareServiceConfig
|
||||
} from '../shared/wechat-share-card'
|
||||
import type {
|
||||
ListTextToSpeechVoicesRequest,
|
||||
SaveTextToSpeechSettingsRequest,
|
||||
SynthesizeTextToSpeechRequest
|
||||
} from '../shared/text-to-speech'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -114,6 +141,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: (
|
||||
@@ -127,6 +155,16 @@ const api = {
|
||||
testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId),
|
||||
testAIVision: (request: AIVisionTestRequest) => ipcRenderer.invoke('ai:testVision', request),
|
||||
migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config),
|
||||
getTextToSpeechSettings: () => ipcRenderer.invoke('tts:getSettings'),
|
||||
saveTextToSpeechSettings: (request: SaveTextToSpeechSettingsRequest) =>
|
||||
ipcRenderer.invoke('tts:saveSettings', request),
|
||||
listTextToSpeechVoices: (request?: ListTextToSpeechVoicesRequest) =>
|
||||
ipcRenderer.invoke('tts:listVoices', request),
|
||||
synthesizeTextToSpeech: (request: SynthesizeTextToSpeechRequest) =>
|
||||
ipcRenderer.invoke('tts:synthesize', request),
|
||||
removeGeneratedTextToSpeechAudio: (filePath: string) =>
|
||||
ipcRenderer.invoke('tts:removeGeneratedAudio', filePath),
|
||||
openFishAudioApiKeys: () => ipcRenderer.invoke('tts:openApiKeys'),
|
||||
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
|
||||
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
|
||||
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
||||
@@ -198,6 +236,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 +245,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'),
|
||||
@@ -299,6 +349,37 @@ const api = {
|
||||
limit?: number
|
||||
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
|
||||
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
||||
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
|
||||
ipcRenderer.invoke('wechat-personal:getStatus'),
|
||||
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
|
||||
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
||||
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
||||
ipcRenderer.invoke('wechat-personal:downloadRuntime'),
|
||||
cancelPersonalWechatRuntimeDownload: (): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('wechat-personal:cancelRuntimeDownload'),
|
||||
removePersonalWechatRuntime: (): Promise<PersonalWechatRuntimeStatus> =>
|
||||
ipcRenderer.invoke('wechat-personal:removeRuntime'),
|
||||
openPersonalWechatRuntimeDirectory: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('wechat-personal:openRuntimeDirectory'),
|
||||
onPersonalWechatRuntimeProgress: (
|
||||
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
status: PersonalWechatRuntimeProgressEvent
|
||||
): void => callback(status)
|
||||
ipcRenderer.on('wechat-personal:runtimeProgress', listener)
|
||||
return () => ipcRenderer.removeListener('wechat-personal:runtimeProgress', listener)
|
||||
},
|
||||
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
||||
ipcRenderer.invoke('wechat-personal:rebind'),
|
||||
selectPersonalWechatImage: (): Promise<PersonalWechatImageSelectionResult> =>
|
||||
ipcRenderer.invoke('wechat-personal:selectImage'),
|
||||
selectPersonalWechatVoice: (): Promise<PersonalWechatVoiceSelectionResult> =>
|
||||
ipcRenderer.invoke('wechat-personal:selectVoice'),
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
||||
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' https: blob: data: wxe-media:;"
|
||||
/>
|
||||
</head>
|
||||
|
||||
|
||||
+189
-14
@@ -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,9 @@ 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'
|
||||
import { runtimePlatform } from './utils/runtime-environment'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -71,6 +78,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 +259,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 +396,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 +427,8 @@ function App(): React.ReactElement {
|
||||
sourceContact: reportSourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
modelConfig: aiModelConfig
|
||||
modelConfig: reportTextModelConfig,
|
||||
visionModelConfig: aiVisionModelConfig
|
||||
})
|
||||
const lastCapturedReportKeyRef = React.useRef('')
|
||||
|
||||
@@ -1390,6 +1471,11 @@ function App(): React.ReactElement {
|
||||
setActivePage('settings')
|
||||
}
|
||||
|
||||
const openTextToSpeechSettings = (): void => {
|
||||
setSettingsCategory('text-to-speech')
|
||||
setActivePage('settings')
|
||||
}
|
||||
|
||||
const dismissFirstUseWelcome = (): void => {
|
||||
try {
|
||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||
@@ -1462,7 +1548,9 @@ function App(): React.ReactElement {
|
||||
reportGeneration.phase !== 'success' ||
|
||||
!reportSourceContact ||
|
||||
!reportGeneration.generatedImage ||
|
||||
!reportGeneration.reportPaths
|
||||
!reportGeneration.reportPaths ||
|
||||
!reportGeneration.reportSnapshot ||
|
||||
!reportGeneration.reportMetadata
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -1472,6 +1560,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 +1580,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,17 +1608,26 @@ 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
|
||||
])
|
||||
|
||||
const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
|
||||
const selectedReportContact = selectedReport
|
||||
? contacts.find((contact) => contact.md5 === selectedReport.contactId) || null
|
||||
: null
|
||||
|
||||
const openReportResult = (): void => {
|
||||
if (isSavingGeneratedReport) {
|
||||
@@ -1564,6 +1669,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 }> => {
|
||||
@@ -1607,6 +1736,7 @@ function App(): React.ReactElement {
|
||||
onReloadAvatars={handleReloadCurrentAvatars}
|
||||
onLoadOlderMessages={handleLoadOlderMessages}
|
||||
onCreateGroupReport={handleOpenReportWorkspace}
|
||||
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
||||
isAiLoading={reportGeneration.isGenerating}
|
||||
jumpToTime={archiveJumpTime}
|
||||
/>
|
||||
@@ -1634,6 +1764,8 @@ function App(): React.ReactElement {
|
||||
onRegenerate={handleRegenerateReport}
|
||||
onCopyImage={handleCopyReportImage}
|
||||
onReveal={handleRevealReport}
|
||||
onSwitchTemplate={handleSwitchReportTemplate}
|
||||
sendTarget={selectedReportContact}
|
||||
/>
|
||||
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
||||
</div>
|
||||
@@ -1652,7 +1784,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 +1800,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 +1829,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 +1871,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}
|
||||
@@ -1837,7 +2012,7 @@ function App(): React.ReactElement {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<DatabaseConnectionPage
|
||||
platform={window.electron.process.platform}
|
||||
platform={runtimePlatform}
|
||||
mode={databaseConnectionMode}
|
||||
dbKey={dbKey}
|
||||
dbRoot={dbRootInput}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||
import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
@@ -18,6 +19,7 @@ interface ChatWindowProps {
|
||||
onReloadAvatars?: () => Promise<void>
|
||||
onLoadOlderMessages?: () => Promise<void>
|
||||
onCreateGroupReport?: () => void
|
||||
onOpenTextToSpeechSettings?: () => void
|
||||
isAiLoading?: boolean
|
||||
jumpToTime?: number | null
|
||||
}
|
||||
@@ -34,6 +36,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onReloadAvatars,
|
||||
onLoadOlderMessages,
|
||||
onCreateGroupReport,
|
||||
onOpenTextToSpeechSettings,
|
||||
isAiLoading = false,
|
||||
jumpToTime
|
||||
}) => {
|
||||
@@ -53,6 +56,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false)
|
||||
const previousScrollTopRef = useRef(0)
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
@@ -199,6 +203,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshData={onRefreshData}
|
||||
onTestSend={() => setSendDialogOpen(true)}
|
||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||
/>
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
@@ -227,6 +232,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onJumpToLatest={scrollToBottom}
|
||||
/>
|
||||
|
||||
{sendDialogOpen && (
|
||||
<PersonalWechatSendDialog
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
onClose={() => setSendDialogOpen(false)}
|
||||
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewImage && (
|
||||
<div className="image-viewer-overlay" onClick={closeImagePreview}>
|
||||
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
|
||||
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { isWindows } from '../utils/runtime-environment'
|
||||
|
||||
interface SelfInfo {
|
||||
wxid: string
|
||||
@@ -66,7 +67,6 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
onSaveAiModelConfig,
|
||||
onDbRootChanged
|
||||
}) => {
|
||||
const isWindows = window.electron.process.platform === 'win32'
|
||||
const dbRootPlaceholder = isWindows
|
||||
? 'C:\\Users\\你\\Documents\\WeChat Files'
|
||||
: '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
|
||||
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
contact: Contact
|
||||
@@ -13,6 +14,7 @@ interface ChatHeaderProps {
|
||||
onContentFilterChange: (value: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onTestSend: () => void
|
||||
onOpenAiSettings: () => void
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ export function ChatHeader({
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onTestSend,
|
||||
onOpenAiSettings
|
||||
}: ChatHeaderProps): React.ReactElement {
|
||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||
@@ -111,6 +114,22 @@ export function ChatHeader({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="chat-tool-button-wrapper"
|
||||
title={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
|
||||
aria-label={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
|
||||
tabIndex={supportsPersonalWechatSend ? -1 : 0}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-tool-button"
|
||||
onClick={onTestSend}
|
||||
disabled={!supportsPersonalWechatSend}
|
||||
>
|
||||
<SendIcon />
|
||||
<span>发送消息</span>
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-ai-button"
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
|
||||
|
||||
type SendMode = 'image' | 'voice'
|
||||
type VoiceSource = 'generated' | 'file'
|
||||
type SelectedLocalFile = { path: string; name: string }
|
||||
|
||||
interface PersonalWechatSendDialogProps {
|
||||
contact: Contact
|
||||
isGroupChat: boolean
|
||||
onClose: () => void
|
||||
onOpenTextToSpeechSettings?: () => void
|
||||
initialMode?: SendMode
|
||||
initialImage?: SelectedLocalFile | null
|
||||
}
|
||||
|
||||
type GeneratedVoice = { filePath: string; audioDataUrl: string }
|
||||
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
|
||||
|
||||
function formatAudioTime(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) return '0:00'
|
||||
const seconds = Math.floor(value)
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function statusLabel(status: PersonalWechatSenderStatus | null): string {
|
||||
if (!status) return '正在检测'
|
||||
if (status.state === 'online') return '个人微信已绑定'
|
||||
if (status.state === 'stopped') return '尚未绑定'
|
||||
if (status.state === 'runtime_missing') return '运行时未安装'
|
||||
if (status.state === 'hook_not_ready') return '已绑定,等待消息初始化'
|
||||
if (status.state === 'unsupported_version') return '微信版本不匹配'
|
||||
if (status.state === 'wechat_not_running') return '微信未运行'
|
||||
if (status.state === 'sip_enabled') return 'SIP 未关闭'
|
||||
if (status.state === 'unsupported_platform') return '当前平台不支持'
|
||||
if (status.state === 'starting') return '正在连接'
|
||||
if (status.state === 'rebinding') return '正在重新绑定'
|
||||
if (status.state === 'error') return '绑定异常'
|
||||
return '正在检测'
|
||||
}
|
||||
|
||||
function readyText(ready: boolean, readyLabel = '正常', waitingLabel = '等待初始化'): string {
|
||||
return ready ? readyLabel : waitingLabel
|
||||
}
|
||||
|
||||
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
||||
if (!status) return '正在检查微信版本、进程和 OneBot Hook…'
|
||||
if (status.state === 'unsupported_version') {
|
||||
return `当前微信版本 ${status.wechatVersion || '未知'} 暂不支持。请在设置中查看支持的微信版本,安装完全一致的版本后再试。`
|
||||
}
|
||||
if (status.state === 'runtime_missing') {
|
||||
return '微信发送组件尚未安装。请前往文字转语音设置下载组件后再试。'
|
||||
}
|
||||
return status.message
|
||||
}
|
||||
|
||||
export function PersonalWechatSendDialog({
|
||||
contact,
|
||||
isGroupChat,
|
||||
onClose,
|
||||
onOpenTextToSpeechSettings,
|
||||
initialMode = 'image',
|
||||
initialImage = null
|
||||
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||
const [mode, setMode] = useState<SendMode>(initialMode)
|
||||
const [image, setImage] = useState<SelectedLocalFile | null>(initialImage)
|
||||
const [voice, setVoice] = useState<SelectedLocalFile | null>(null)
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>('generated')
|
||||
const [voiceText, setVoiceText] = useState('1')
|
||||
const [ttsSettings, setTtsSettings] = useState<TextToSpeechSettings | null>(null)
|
||||
const [ttsVoices, setTtsVoices] = useState<TextToSpeechVoice[]>([])
|
||||
const [generatedVoice, setGeneratedVoice] = useState<GeneratedVoice | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
|
||||
const [previewCurrentTime, setPreviewCurrentTime] = useState(0)
|
||||
const [previewDuration, setPreviewDuration] = useState(0)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isRebinding, setIsRebinding] = useState(false)
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
|
||||
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||
const targetId = contact.m_nsUsrName
|
||||
const selectedTypeReady = mode === 'voice' ? status?.canSendVoice : status?.canSendImage
|
||||
const hasContent =
|
||||
mode === 'voice'
|
||||
? voiceSource === 'file'
|
||||
? Boolean(voice?.path)
|
||||
: Boolean(voiceText.trim())
|
||||
: Boolean(image?.path)
|
||||
const isBusy = isSending || isGenerating || isRebinding
|
||||
const generatedVoiceReady = Boolean(
|
||||
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
|
||||
)
|
||||
const canSubmit = Boolean(
|
||||
selectedTypeReady && hasContent && !isBusy && (mode !== 'voice' || voiceSource !== 'generated')
|
||||
)
|
||||
const canGenerate = Boolean(generatedVoiceReady && !isBusy)
|
||||
const canSendGenerated = Boolean(status?.canSendVoice && generatedVoice && !isBusy)
|
||||
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
||||
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
|
||||
const statusTone = useMemo(() => {
|
||||
if (status?.attachReady && status.baseAddressReady) return 'ready'
|
||||
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
|
||||
return 'blocked'
|
||||
}, [status])
|
||||
|
||||
const clearGeneratedVoice = useCallback((removeFile = true): void => {
|
||||
const current = generatedVoiceRef.current
|
||||
generatedVoiceRef.current = null
|
||||
generatedAudioRef.current?.pause()
|
||||
setGeneratedVoice(null)
|
||||
setIsPreviewPlaying(false)
|
||||
setPreviewCurrentTime(0)
|
||||
setPreviewDuration(0)
|
||||
if (removeFile && current?.filePath) {
|
||||
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (isBusy) return
|
||||
clearGeneratedVoice()
|
||||
onClose()
|
||||
}, [clearGeneratedVoice, isBusy, onClose])
|
||||
|
||||
const handleOpenTextToSpeechSettings = (showSupportedVersions = false): void => {
|
||||
if (!onOpenTextToSpeechSettings || isBusy) return
|
||||
if (showSupportedVersions) {
|
||||
try {
|
||||
sessionStorage.setItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY, '1')
|
||||
} catch {
|
||||
// The settings page can still open if session storage is unavailable.
|
||||
}
|
||||
}
|
||||
clearGeneratedVoice()
|
||||
onClose()
|
||||
onOpenTextToSpeechSettings()
|
||||
}
|
||||
|
||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||
setResult(null)
|
||||
try {
|
||||
setStatus(await window.api.getPersonalWechatSenderStatus())
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
platform: 'unknown',
|
||||
arch: 'unknown',
|
||||
sipDisabled: false,
|
||||
wechatRunning: false,
|
||||
runtimeReady: false,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: false,
|
||||
attachReady: false,
|
||||
baseAddressReady: false,
|
||||
textHookInstalled: false,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: false,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false,
|
||||
message: '无法检测个人微信发送服务',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus()
|
||||
}, [refreshStatus])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getTextToSpeechSettings()
|
||||
.then(async (response) => {
|
||||
if (!active) return
|
||||
setTtsSettings(response.settings)
|
||||
if (!response.settings.hasApiKey) return
|
||||
const voicesResponse = await window.api.listTextToSpeechVoices({
|
||||
pageNumber: 1,
|
||||
pageSize: 24
|
||||
})
|
||||
if (!active || !voicesResponse.success) return
|
||||
setTtsVoices(voicesResponse.items)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const current = generatedVoiceRef.current
|
||||
generatedVoiceRef.current = null
|
||||
if (current?.filePath) {
|
||||
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = generatedAudioRef.current
|
||||
return () => audio?.pause()
|
||||
}, [generatedVoice])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape' && !isBusy) handleClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleClose, isBusy])
|
||||
|
||||
const handleRebind = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
setIsRebinding(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const nextStatus = await window.api.rebindPersonalWechatSender()
|
||||
setStatus(nextStatus)
|
||||
setResult({
|
||||
success: nextStatus.attachReady && nextStatus.baseAddressReady,
|
||||
message: nextStatus.message
|
||||
})
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
} finally {
|
||||
setIsRebinding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectImage = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
const selection = await window.api.selectPersonalWechatImage()
|
||||
if (!selection.canceled && selection.path) {
|
||||
setImage({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
||||
setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectVoice = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
const selection = await window.api.selectPersonalWechatVoice()
|
||||
if (!selection.canceled && selection.path) {
|
||||
setVoice({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
||||
setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
const changeMode = (nextMode: SendMode): void => {
|
||||
if (mode === 'voice' && voiceSource === 'generated' && nextMode !== 'voice') {
|
||||
clearGeneratedVoice()
|
||||
}
|
||||
setMode(nextMode)
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const changeVoiceSource = (nextSource: VoiceSource): void => {
|
||||
if (voiceSource === 'generated' && nextSource !== 'generated') clearGeneratedVoice()
|
||||
setVoiceSource(nextSource)
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const changeVoiceText = (nextText: string): void => {
|
||||
if (generatedVoiceRef.current) clearGeneratedVoice()
|
||||
setVoiceText(nextText)
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleGenerateVoice = async (): Promise<void> => {
|
||||
if (!canGenerate) return
|
||||
clearGeneratedVoice()
|
||||
setIsGenerating(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const generated = await window.api.synthesizeTextToSpeech({
|
||||
text: voiceText.trim(),
|
||||
referenceId: ttsSettings!.selectedVoiceId
|
||||
})
|
||||
if (!generated.success || !generated.filePath || !generated.audioDataUrl) {
|
||||
setResult({ success: false, message: generated.error || '语音生成失败' })
|
||||
return
|
||||
}
|
||||
const nextVoice = {
|
||||
filePath: generated.filePath,
|
||||
audioDataUrl: generated.audioDataUrl
|
||||
}
|
||||
generatedVoiceRef.current = nextVoice
|
||||
setGeneratedVoice(nextVoice)
|
||||
setResult({ success: true, message: '语音已生成,可以先试听,确认后再发送' })
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleGeneratedPreview = async (): Promise<void> => {
|
||||
const audio = generatedAudioRef.current
|
||||
if (!audio) return
|
||||
if (!audio.paused) {
|
||||
audio.pause()
|
||||
setIsPreviewPlaying(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await audio.play()
|
||||
setIsPreviewPlaying(true)
|
||||
} catch {
|
||||
setResult({ success: false, message: '语音试听播放失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendGeneratedVoice = async (): Promise<void> => {
|
||||
const current = generatedVoiceRef.current
|
||||
if (!canSendGenerated || !current) return
|
||||
setIsSending(true)
|
||||
setResult(null)
|
||||
generatedAudioRef.current?.pause()
|
||||
setIsPreviewPlaying(false)
|
||||
try {
|
||||
const response = await window.api.sendPersonalWechatMessage({
|
||||
type: 'voice',
|
||||
to: targetId,
|
||||
filePath: current.filePath,
|
||||
isGroup: isGroupChat
|
||||
})
|
||||
setStatus(response.status)
|
||||
setResult({
|
||||
success: response.success,
|
||||
message: response.success
|
||||
? '语音已提交给微信;请从另一设备或群成员处确认实际送达'
|
||||
: response.error || '发送失败'
|
||||
})
|
||||
if (response.success) clearGeneratedVoice()
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async (): Promise<void> => {
|
||||
if (!canSubmit || (mode === 'voice' && voiceSource === 'generated')) return
|
||||
setIsSending(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const response = await window.api.sendPersonalWechatMessage(
|
||||
mode === 'voice'
|
||||
? { type: 'voice', to: targetId, filePath: voice!.path, isGroup: isGroupChat }
|
||||
: { type: 'image', to: targetId, filePath: image!.path, isGroup: isGroupChat }
|
||||
)
|
||||
setStatus(response.status)
|
||||
setResult({
|
||||
success: response.success,
|
||||
message: response.success
|
||||
? `${mode === 'voice' ? '语音' : '图片'}已提交给微信;请从另一设备或群成员处确认实际送达`
|
||||
: response.error || '发送失败'
|
||||
})
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusItems = [
|
||||
['微信进程', status?.wechatPid ? `PID ${status.wechatPid}` : '未检测到'],
|
||||
[
|
||||
'OneBot',
|
||||
status?.oneBotPid
|
||||
? `PID ${status.oneBotPid}${status.boundWechatPid ? ` · 绑定 ${status.boundWechatPid}` : ''}`
|
||||
: '未启动'
|
||||
],
|
||||
[
|
||||
'接口',
|
||||
`${status?.endpoint || '127.0.0.1:58080'} · ${readyText(Boolean(status?.endpointReady), '监听中', '未监听')}`
|
||||
],
|
||||
['基址扫描', status?.baseAddress || readyText(Boolean(status?.baseAddressReady))],
|
||||
['图片 Hook', readyText(Boolean(status?.imageHookReady), '已捕获,可发送', '等待手动发图片')],
|
||||
['语音能力', readyText(Boolean(status?.canSendVoice), '可发送', '等待媒体 Hook 初始化')],
|
||||
['消息监听', readyText(Boolean(status?.messageListenerReady), '正常', '等待收到微信消息')]
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="personal-wechat-send-backdrop" role="presentation" onMouseDown={handleClose}>
|
||||
<section
|
||||
className="personal-wechat-send-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="personal-wechat-send-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="personal-wechat-send-kicker">实验性功能</span>
|
||||
<h2 id="personal-wechat-send-title">个人微信测试发送</h2>
|
||||
</div>
|
||||
<button type="button" className="personal-wechat-send-close" onClick={handleClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="personal-wechat-send-device-note" role="note">
|
||||
<span aria-hidden>i</span>
|
||||
<p>
|
||||
<strong>请在其他设备确认发送结果</strong>
|
||||
通过测试功能发送的消息不会显示在本机微信会话中,请切换到手机、平板等其他设备确认是否送达。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="personal-wechat-send-target">
|
||||
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
||||
<strong>{displayName}</strong>
|
||||
<code>{targetId}</code>
|
||||
</div>
|
||||
|
||||
<div className={`personal-wechat-send-status ${statusTone}`}>
|
||||
<span className="personal-wechat-send-status-dot" />
|
||||
<div>
|
||||
<strong>{statusLabel(status)}</strong>
|
||||
<p>{statusDescription(status)}</p>
|
||||
{status?.wechatVersion && <small>微信版本:{status.wechatVersion}</small>}
|
||||
{status?.error &&
|
||||
status.state !== 'unsupported_version' &&
|
||||
status.state !== 'runtime_missing' && <small className="error">{status.error}</small>}
|
||||
</div>
|
||||
<div className="personal-wechat-send-status-actions">
|
||||
{(status?.state === 'unsupported_version' || status?.state === 'runtime_missing') &&
|
||||
onOpenTextToSpeechSettings ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleOpenTextToSpeechSettings(status.state === 'unsupported_version')
|
||||
}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{status.state === 'runtime_missing' ? '前往下载组件' : '查看支持版本'}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => void refreshStatus()} disabled={isBusy}>
|
||||
重新检测
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleRebind()} disabled={isBusy}>
|
||||
{isRebinding ? '绑定中…' : '尝试重新绑定'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="personal-wechat-send-diagnostics">
|
||||
{statusItems.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="personal-wechat-send-mode" role="radiogroup" aria-label="测试消息类型">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === 'image'}
|
||||
className={mode === 'image' ? 'active' : ''}
|
||||
onClick={() => changeMode('image')}
|
||||
disabled={isBusy}
|
||||
>
|
||||
图片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === 'voice'}
|
||||
className={mode === 'voice' ? 'active' : ''}
|
||||
onClick={() => changeMode('voice')}
|
||||
disabled={isBusy}
|
||||
>
|
||||
语音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'image' ? (
|
||||
<div className="personal-wechat-send-image-picker">
|
||||
<span>图片</span>
|
||||
<button type="button" onClick={() => void handleSelectImage()} disabled={isBusy}>
|
||||
{image ? '重新选择图片' : '选择图片'}
|
||||
</button>
|
||||
{image ? (
|
||||
<div>
|
||||
<strong>{image.name}</strong>
|
||||
<small>{image.path}</small>
|
||||
</div>
|
||||
) : (
|
||||
<small>支持 PNG、JPG、GIF 和 WebP,最大 20 MB</small>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="personal-wechat-voice-composer">
|
||||
<div className="personal-wechat-voice-source" role="radiogroup" aria-label="语音来源">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={voiceSource === 'generated'}
|
||||
className={voiceSource === 'generated' ? 'active' : ''}
|
||||
onClick={() => changeVoiceSource('generated')}
|
||||
disabled={isBusy}
|
||||
>
|
||||
输入文字生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={voiceSource === 'file'}
|
||||
className={voiceSource === 'file' ? 'active' : ''}
|
||||
onClick={() => changeVoiceSource('file')}
|
||||
disabled={isBusy}
|
||||
>
|
||||
选择本地文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceSource === 'generated' ? (
|
||||
<div className="personal-wechat-generated-voice">
|
||||
<div className="personal-wechat-generated-voice-heading">
|
||||
<div>
|
||||
<span>文字生成语音</span>
|
||||
<strong>{selectedTtsVoice?.name || '尚未选择音色'}</strong>
|
||||
</div>
|
||||
{onOpenTextToSpeechSettings ? (
|
||||
<button type="button" onClick={() => handleOpenTextToSpeechSettings()}>
|
||||
前往文字转语音设置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="personal-wechat-send-editor">
|
||||
<span>要生成的文字</span>
|
||||
<textarea
|
||||
aria-label="要生成的文字"
|
||||
value={voiceText}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => changeVoiceText(event.target.value)}
|
||||
/>
|
||||
<small>{voiceText.length} / 1000</small>
|
||||
</label>
|
||||
<div
|
||||
className={`personal-wechat-tts-readiness ${ttsSettings?.hasApiKey ? 'ready' : ''}`}
|
||||
>
|
||||
<strong>
|
||||
{generatedVoiceReady
|
||||
? 'API Key 与音色已准备'
|
||||
: ttsSettings?.hasApiKey
|
||||
? '还需要在设置中选择音色'
|
||||
: '还需要配置语音服务 API Key'}
|
||||
</strong>
|
||||
<span>点击右下角“生成语音”,生成完成后可以试听,再决定是否发送。</span>
|
||||
</div>
|
||||
{isGenerating ? (
|
||||
<div className="personal-wechat-generation-progress" aria-live="polite">
|
||||
<div>
|
||||
<strong>正在生成语音…</strong>
|
||||
<span>通常需要几秒,请稍候</span>
|
||||
</div>
|
||||
<div className="personal-wechat-generation-track">
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
) : generatedVoice ? (
|
||||
<div className="personal-wechat-generated-result">
|
||||
<audio
|
||||
ref={generatedAudioRef}
|
||||
src={generatedVoice.audioDataUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
||||
onTimeUpdate={(event) =>
|
||||
setPreviewCurrentTime(event.currentTarget.currentTime)
|
||||
}
|
||||
onPause={() => setIsPreviewPlaying(false)}
|
||||
onPlay={() => setIsPreviewPlaying(true)}
|
||||
onEnded={() => {
|
||||
setIsPreviewPlaying(false)
|
||||
setPreviewCurrentTime(0)
|
||||
}}
|
||||
/>
|
||||
<div className="personal-wechat-generated-result-copy">
|
||||
<strong>语音生成完成</strong>
|
||||
<span>
|
||||
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
|
||||
</span>
|
||||
<div className="personal-wechat-preview-track">
|
||||
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="personal-wechat-generated-result-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleGeneratedPreview()}
|
||||
disabled={isSending}
|
||||
>
|
||||
{isPreviewPlaying ? '暂停' : '播放'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => void handleSendGeneratedVoice()}
|
||||
disabled={!canSendGenerated}
|
||||
>
|
||||
{isSending ? '正在发送…' : `发送到${isGroupChat ? '群聊' : '联系人'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="personal-wechat-send-image-picker">
|
||||
<span>本地语音文件</span>
|
||||
<button type="button" onClick={() => void handleSelectVoice()} disabled={isBusy}>
|
||||
{voice ? '重新选择语音' : '选择语音'}
|
||||
</button>
|
||||
{voice ? (
|
||||
<div>
|
||||
<strong>{voice.name}</strong>
|
||||
<small>{voice.path}</small>
|
||||
</div>
|
||||
) : (
|
||||
<small>支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC,最大 20 MB</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="personal-wechat-send-note">
|
||||
{mode === 'voice' && voiceSource === 'generated'
|
||||
? '文字生成语音会复用现有媒体上传能力;请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
||||
: mode === 'voice'
|
||||
? '本地语音复用媒体上传 Hook,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
||||
: '如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'}
|
||||
微信重新登录导致 PID 改变时,请点击“尝试重新绑定”。
|
||||
</p>
|
||||
|
||||
{result && (
|
||||
<div className={`personal-wechat-send-result ${result.success ? 'success' : 'error'}`}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer>
|
||||
<button type="button" className="secondary" onClick={handleClose} disabled={isBusy}>
|
||||
取消
|
||||
</button>
|
||||
{mode === 'voice' && voiceSource === 'generated' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
disabled={!canGenerate}
|
||||
onClick={() => void handleGenerateVoice()}
|
||||
>
|
||||
{isGenerating ? '正在生成语音…' : generatedVoice ? '重新生成语音' : '生成语音'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="primary" disabled={!canSubmit} onClick={handleSend}>
|
||||
{isSending
|
||||
? '正在发送…'
|
||||
: `测试发送${mode === 'voice' ? '语音' : '图片'}到${isGroupChat ? '群聊' : '联系人'}`}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,15 @@ export function AiIcon({ className }: IconProps): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
export function SendIcon({ className }: IconProps): React.ReactElement {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="m4 5 16 7-16 7 3-7-3-7Z" />
|
||||
<path d="M7 12h13" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CloseIcon({ className }: IconProps): React.ReactElement {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
|
||||
@@ -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('手机端 · 375–414 px', mobileTemplates)}
|
||||
{renderGroup('电脑端 · 1280–1920 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&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,43 +1,116 @@
|
||||
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
|
||||
canSendToGroup?: boolean
|
||||
sendToGroupHint?: string
|
||||
currentTemplateId?: SelectableReportTemplateId
|
||||
isSwitchingTemplate: boolean
|
||||
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
|
||||
onRegenerate: () => void
|
||||
onCopyImage: () => void
|
||||
onReveal: () => void
|
||||
onShare: () => void
|
||||
onSendToGroup?: () => void
|
||||
}
|
||||
|
||||
export function ReportToolbar({
|
||||
canCopyImage,
|
||||
canReveal,
|
||||
canShare,
|
||||
canSwitchTemplate,
|
||||
canSendToGroup = false,
|
||||
sendToGroupHint = '当前报告暂时无法发送',
|
||||
currentTemplateId,
|
||||
isSwitchingTemplate,
|
||||
onSwitchTemplate,
|
||||
onRegenerate,
|
||||
onCopyImage,
|
||||
onReveal
|
||||
onReveal,
|
||||
onShare,
|
||||
onSendToGroup
|
||||
}: 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>
|
||||
<button type="button" disabled={!canCopyImage} onClick={onCopyImage}>
|
||||
复制图片
|
||||
</button>
|
||||
<span
|
||||
className="report-toolbar-button-hint"
|
||||
title={sendToGroupHint}
|
||||
aria-label={sendToGroupHint}
|
||||
tabIndex={canSendToGroup ? -1 : 0}
|
||||
>
|
||||
<button type="button" disabled={!canSendToGroup} onClick={() => onSendToGroup?.()}>
|
||||
发送到当前群聊
|
||||
</button>
|
||||
</span>
|
||||
<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,11 @@ 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 type { Contact } from '../../../../shared/types'
|
||||
import { WechatShareCardDialog } from './WechatShareCardDialog'
|
||||
import { PersonalWechatSendDialog } from '../chat/PersonalWechatSendDialog'
|
||||
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||
|
||||
interface ReportViewerProps {
|
||||
report: GeneratedReportRecord | null
|
||||
@@ -11,19 +16,49 @@ 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 }>
|
||||
sendTarget?: Contact | null
|
||||
personalWechatSendSupported?: boolean
|
||||
}
|
||||
|
||||
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,
|
||||
sendTarget = null,
|
||||
personalWechatSendSupported = supportsPersonalWechatSend
|
||||
}: 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 [sendDialogOpen, setSendDialogOpen] = useState(false)
|
||||
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -31,21 +66,67 @@ export function ReportViewer({
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
setStatus('')
|
||||
setImageError('')
|
||||
setIsSwitchingTemplate(false)
|
||||
setShareDialogOpen(false)
|
||||
setSendDialogOpen(false)
|
||||
setZoom(1)
|
||||
setFitZoom(1)
|
||||
setNaturalSize(null)
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [report?.id])
|
||||
|
||||
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
|
||||
const sendToGroupHint = !personalWechatSendSupported
|
||||
? '仅支持 macOS'
|
||||
: !sendTarget
|
||||
? '未找到这份日报对应的群聊'
|
||||
: !report?.pngPath
|
||||
? '当前报告缺少可发送的 PNG 文件'
|
||||
: '打开确认窗口,将日报图片发送到当前群聊'
|
||||
const canSendToGroup = Boolean(personalWechatSendSupported && sendTarget && report?.pngPath)
|
||||
|
||||
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 +139,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 +185,22 @@ export function ReportViewer({
|
||||
<ReportToolbar
|
||||
canCopyImage={Boolean(report.generatedImage)}
|
||||
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
||||
canShare={Boolean(report.pngPath)}
|
||||
canSendToGroup={canSendToGroup}
|
||||
sendToGroupHint={sendToGroupHint}
|
||||
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)}
|
||||
onSendToGroup={() => setSendDialogOpen(true)}
|
||||
/>
|
||||
</header>
|
||||
{status && <div className="report-viewer-status">{status}</div>}
|
||||
@@ -101,7 +211,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 +223,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 +252,32 @@ 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)}
|
||||
/>
|
||||
)}
|
||||
{sendDialogOpen && report.pngPath && sendTarget && (
|
||||
<PersonalWechatSendDialog
|
||||
contact={sendTarget}
|
||||
isGroupChat
|
||||
initialMode="image"
|
||||
initialImage={{
|
||||
path: report.pngPath,
|
||||
name: report.pngPath.split(/[\\/]/).pop() || '群聊日报.png'
|
||||
}}
|
||||
onClose={() => setSendDialogOpen(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
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,9 @@ export type {
|
||||
ReportAssetStatus,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
SaveGeneratedReportResult,
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../../../../shared/report-history'
|
||||
|
||||
export type ReportWorkspaceView = 'configure' | 'result'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CacheCleanupPage } from './pages/CacheCleanupPage'
|
||||
import { AppearancePage } from './pages/AppearancePage'
|
||||
import { AboutPage } from './pages/AboutPage'
|
||||
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
||||
import { TextToSpeechPage } from './pages/TextToSpeechPage'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||
|
||||
@@ -91,6 +92,8 @@ export function SettingsWorkspace({
|
||||
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||
case 'voice-recognition':
|
||||
return <VoiceRecognitionPage onNotice={onNotice} />
|
||||
case 'text-to-speech':
|
||||
return <TextToSpeechPage onNotice={onNotice} />
|
||||
case 'recall-protection':
|
||||
return <RecallProtectionPage onNotice={onNotice} />
|
||||
case 'advanced':
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import type { DatabaseKeyState } from './types'
|
||||
import { runtimePlatform } from '../../../utils/runtime-environment'
|
||||
|
||||
const PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成']
|
||||
|
||||
@@ -14,7 +15,7 @@ export function DatabaseKeyAutoDetect({
|
||||
onRefresh: () => void
|
||||
}): React.ReactElement {
|
||||
const environment = state.environment
|
||||
const platform = environment?.platform || window.electron.process.platform
|
||||
const platform = environment?.platform || runtimePlatform
|
||||
if (platform !== 'win32') {
|
||||
return (
|
||||
<section className="settings-card database-key-auto database-key-auto-manual">
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
||||
label: '智能能力',
|
||||
items: [
|
||||
{ id: 'voice-recognition', label: '语音转文字' },
|
||||
{ id: 'text-to-speech', label: '文字转语音' },
|
||||
{ id: 'ai-model', label: 'AI 模型' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
|
||||
| 'database-key'
|
||||
| 'image-key'
|
||||
| 'voice-recognition'
|
||||
| 'text-to-speech'
|
||||
| 'ai-model'
|
||||
| 'recall-protection'
|
||||
| 'local-api'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,931 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
TextToSpeechModel,
|
||||
TextToSpeechSettings,
|
||||
TextToSpeechVoice
|
||||
} from '../../../../../shared/text-to-speech'
|
||||
import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime'
|
||||
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
||||
|
||||
const VOICE_PAGE_SIZE = 24
|
||||
const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
|
||||
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
|
||||
const BUNDLED_WECHAT_VERSIONS = [
|
||||
'4.1.6.12',
|
||||
'4.1.6.46',
|
||||
'4.1.6.47',
|
||||
'4.1.7.31',
|
||||
'4.1.7.55',
|
||||
'4.1.7.57',
|
||||
'4.1.8.28',
|
||||
'4.1.8.29',
|
||||
'4.1.8.104',
|
||||
'4.1.8.107',
|
||||
'4.1.9.52',
|
||||
'4.1.9.55',
|
||||
'4.1.9.58',
|
||||
'4.1.10.53',
|
||||
'4.1.11.53'
|
||||
] as const
|
||||
|
||||
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
|
||||
missing: '未下载',
|
||||
downloading: '下载中',
|
||||
ready: '已就绪',
|
||||
invalid: '需要修复',
|
||||
error: '下载失败',
|
||||
unsupported: '暂不支持'
|
||||
}
|
||||
|
||||
const VOICE_FILTERS = [
|
||||
{ value: 'male', label: '男性' },
|
||||
{ value: 'female', label: '女性' },
|
||||
{ value: 'neutral', label: '中性' },
|
||||
{ value: 'young', label: '年轻' },
|
||||
{ value: 'middle-aged', label: '中年' },
|
||||
{ value: 'narration', label: '旁白' },
|
||||
{ value: 'social-media', label: '社交媒体' },
|
||||
{ value: 'sexy', label: '性感' },
|
||||
{ value: 'documentary', label: '纪录片' },
|
||||
{ value: 'deep', label: '深沉' },
|
||||
{ value: 'soft', label: '柔和' },
|
||||
{ value: 'dramatic', label: '戏剧感' },
|
||||
{ value: 'mysterious', label: '神秘' },
|
||||
{ value: 'anime', label: '动漫' }
|
||||
] as const
|
||||
|
||||
const VOICE_FILTER_GROUPS = [
|
||||
['male', 'female', 'neutral'],
|
||||
['young', 'middle-aged']
|
||||
] as const
|
||||
|
||||
const VOICE_TAG_LABELS: Record<string, string> = {
|
||||
zh: '中文',
|
||||
Chinese: '中文',
|
||||
en: '英语',
|
||||
English: '英语',
|
||||
male: '男性',
|
||||
female: '女性',
|
||||
neutral: '中性',
|
||||
young: '年轻',
|
||||
'middle-aged': '中年',
|
||||
old: '年长',
|
||||
conversational: '对话',
|
||||
narration: '旁白',
|
||||
'character-voice': '角色声音',
|
||||
'social-media': '社交媒体',
|
||||
educational: '教育',
|
||||
advertisement: '广告',
|
||||
entertainment: '娱乐',
|
||||
deep: '深沉',
|
||||
low: '低沉',
|
||||
medium: '中等',
|
||||
high: '高亢',
|
||||
soft: '柔和',
|
||||
bright: '明亮',
|
||||
warm: '温暖',
|
||||
dark: '暗沉',
|
||||
raspy: '沙哑',
|
||||
smooth: '顺滑',
|
||||
breathy: '气声',
|
||||
husky: '烟嗓',
|
||||
energetic: '有活力',
|
||||
calm: '沉稳',
|
||||
relaxed: '放松',
|
||||
fast: '快速',
|
||||
slow: '缓慢',
|
||||
measured: '从容',
|
||||
dynamic: '动态',
|
||||
sexy: '性感',
|
||||
friendly: '亲切',
|
||||
professional: '专业',
|
||||
serious: '严肃',
|
||||
cheerful: '欢快',
|
||||
enthusiastic: '热情',
|
||||
confident: '自信',
|
||||
authoritative: '权威',
|
||||
gentle: '温柔',
|
||||
empathetic: '共情',
|
||||
playful: '活泼',
|
||||
dramatic: '戏剧感',
|
||||
intimate: '亲密',
|
||||
mysterious: '神秘',
|
||||
sad: '悲伤',
|
||||
angry: '愤怒',
|
||||
clear: '清晰',
|
||||
crisp: '清脆',
|
||||
'neutral-tone': '中性语气',
|
||||
expressive: '有表现力',
|
||||
monotone: '平稳',
|
||||
animated: '生动',
|
||||
storytelling: '故事感',
|
||||
narrative: '叙事',
|
||||
character: '角色',
|
||||
announcer: '播音',
|
||||
host: '主持',
|
||||
teacher: '教师',
|
||||
coach: '教练',
|
||||
anime: '动漫',
|
||||
gaming: '游戏',
|
||||
cinematic: '电影感',
|
||||
documentary: '纪录片',
|
||||
radio: '电台',
|
||||
podcast: '播客'
|
||||
}
|
||||
|
||||
function compactCount(value?: number): string {
|
||||
if (!value) return '0'
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function voiceMetaTags(voice: TextToSpeechVoice): string[] {
|
||||
return Array.from(new Set([...voice.languages, ...voice.tags])).filter(Boolean)
|
||||
}
|
||||
|
||||
function voiceTagLabel(tag: string): string {
|
||||
return VOICE_TAG_LABELS[tag] || tag
|
||||
}
|
||||
|
||||
function VoiceAvatar({ voice }: { voice: TextToSpeechVoice }): React.ReactElement {
|
||||
return (
|
||||
<span className="tts-voice-media" aria-hidden>
|
||||
<span className="tts-voice-avatar">{voice.name.slice(0, 1)}</span>
|
||||
{voice.coverImage ? (
|
||||
<img
|
||||
src={voice.coverImage}
|
||||
alt=""
|
||||
className="tts-voice-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.hidden = true
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextToSpeechPage({
|
||||
onNotice
|
||||
}: {
|
||||
onNotice: (message: string) => void
|
||||
}): React.ReactElement {
|
||||
const [settings, setSettings] = useState<TextToSpeechSettings | null>(null)
|
||||
const [voices, setVoices] = useState<TextToSpeechVoice[]>([])
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [appliedQuery, setAppliedQuery] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingVoices, setLoadingVoices] = useState(false)
|
||||
const [savingKey, setSavingKey] = useState(false)
|
||||
const [savingVoiceId, setSavingVoiceId] = useState('')
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState('')
|
||||
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
|
||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
||||
const [showWechatVersions, setShowWechatVersions] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
|
||||
|
||||
const loadVoices = useCallback(
|
||||
async (nextPage: number, title: string, append = false, tags: string[] = []): Promise<void> => {
|
||||
setLoadingVoices(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await window.api.listTextToSpeechVoices({
|
||||
pageNumber: nextPage,
|
||||
pageSize: VOICE_PAGE_SIZE,
|
||||
title: title || undefined,
|
||||
tags
|
||||
})
|
||||
if (!result.success) {
|
||||
setError(result.error || '音色加载失败')
|
||||
return
|
||||
}
|
||||
setVoices((current) => {
|
||||
const merged = append ? [...current, ...result.items] : result.items
|
||||
return Array.from(new Map(merged.map((voice) => [voice.id, voice])).values())
|
||||
})
|
||||
setPageNumber(result.pageNumber)
|
||||
setTotal(result.total)
|
||||
setHasMore(result.hasMore)
|
||||
setAppliedQuery(title)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '音色加载失败')
|
||||
} finally {
|
||||
setLoadingVoices(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getTextToSpeechSettings()
|
||||
.then(async (result) => {
|
||||
if (!active) return
|
||||
setSettings(result.settings)
|
||||
setSelectedVoiceId(result.settings.selectedVoiceId)
|
||||
setError(result.success ? '' : result.error || '文字转语音配置读取失败')
|
||||
setLoading(false)
|
||||
if (result.settings.hasApiKey) await loadVoices(1, '')
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!active) return
|
||||
setError(reason instanceof Error ? reason.message : '文字转语音配置读取失败')
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
audioRef.current?.pause()
|
||||
}
|
||||
}, [loadVoices])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY) !== '1') return
|
||||
sessionStorage.removeItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY)
|
||||
setShowWechatVersions(true)
|
||||
} catch {
|
||||
// The page remains usable if session storage is unavailable.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getPersonalWechatRuntimeStatus()
|
||||
.then((status) => active && setRuntimeStatus(status))
|
||||
.catch((reason) => {
|
||||
if (!active) return
|
||||
setRuntimeStatus(null)
|
||||
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
|
||||
})
|
||||
const unsubscribe = window.api.onPersonalWechatRuntimeProgress((status) => {
|
||||
if (active) setRuntimeStatus(status)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedVoice = voices.find((voice) => voice.id === selectedVoiceId)
|
||||
const visibleVoices = useMemo(() => voices, [voices])
|
||||
|
||||
const saveApiKey = async (): Promise<void> => {
|
||||
if (!apiKey.trim() || savingKey) return
|
||||
setSavingKey(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await window.api.saveTextToSpeechSettings({ apiKey: apiKey.trim() })
|
||||
setSettings(result.settings)
|
||||
if (!result.success) {
|
||||
setError(result.error || 'API Key 保存失败')
|
||||
onNotice(result.error || 'API Key 保存失败')
|
||||
return
|
||||
}
|
||||
setApiKey('')
|
||||
onNotice('API Key 已安全保存')
|
||||
await loadVoices(1, '')
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : 'API Key 保存失败'
|
||||
setError(message)
|
||||
onNotice(message)
|
||||
} finally {
|
||||
setSavingKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearApiKey = async (): Promise<void> => {
|
||||
if (!settings?.hasStoredApiKey || savingKey) return
|
||||
setSavingKey(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await window.api.saveTextToSpeechSettings({ clearApiKey: true })
|
||||
setSettings(result.settings)
|
||||
setApiKey('')
|
||||
if (!result.success) {
|
||||
setError(result.error || 'API Key 清除失败')
|
||||
return
|
||||
}
|
||||
onNotice(
|
||||
result.settings.hasEnvironmentApiKey
|
||||
? '已清除应用内 Key,将继续使用应用环境中的 Key'
|
||||
: 'API Key 已清除'
|
||||
)
|
||||
if (!result.settings.hasApiKey) {
|
||||
setVoices([])
|
||||
setTotal(0)
|
||||
setHasMore(false)
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectVoice = async (voice: TextToSpeechVoice): Promise<void> => {
|
||||
if (savingVoiceId) return
|
||||
const previous = selectedVoiceId
|
||||
setSelectedVoiceId(voice.id)
|
||||
setSavingVoiceId(voice.id)
|
||||
setError('')
|
||||
try {
|
||||
const result = await window.api.saveTextToSpeechSettings({ selectedVoiceId: voice.id })
|
||||
setSettings(result.settings)
|
||||
if (!result.success) {
|
||||
setSelectedVoiceId(previous)
|
||||
setError(result.error || '音色保存失败')
|
||||
return
|
||||
}
|
||||
onNotice(`已选择音色:${voice.name}`)
|
||||
} catch (reason) {
|
||||
setSelectedVoiceId(previous)
|
||||
setError(reason instanceof Error ? reason.message : '音色保存失败')
|
||||
} finally {
|
||||
setSavingVoiceId('')
|
||||
}
|
||||
}
|
||||
|
||||
const changeModel = async (model: TextToSpeechModel): Promise<void> => {
|
||||
if (!settings) return
|
||||
const previous = settings.model
|
||||
setSettings({ ...settings, model })
|
||||
const result = await window.api.saveTextToSpeechSettings({ model })
|
||||
if (!result.success) {
|
||||
setSettings({ ...settings, model: previous })
|
||||
setError(result.error || '合成模型保存失败')
|
||||
return
|
||||
}
|
||||
setSettings(result.settings)
|
||||
onNotice(model === 's2.1-pro-free' ? '已切换到标准模型' : '已切换到高质量模型')
|
||||
}
|
||||
|
||||
const searchVoices = async (): Promise<void> => {
|
||||
await loadVoices(1, query.trim(), false, selectedTags)
|
||||
}
|
||||
|
||||
const toggleVoiceFilter = async (tag: string): Promise<void> => {
|
||||
const isSelected = selectedTags.includes(tag)
|
||||
const exclusiveGroup = VOICE_FILTER_GROUPS.find((group) => group.includes(tag as never))
|
||||
const nextTags = isSelected
|
||||
? selectedTags.filter((item) => item !== tag)
|
||||
: [
|
||||
...(exclusiveGroup
|
||||
? selectedTags.filter((item) => !exclusiveGroup.includes(item as never))
|
||||
: selectedTags),
|
||||
tag
|
||||
]
|
||||
setSelectedTags(nextTags)
|
||||
await loadVoices(1, appliedQuery, false, nextTags)
|
||||
}
|
||||
|
||||
const clearVoiceFilters = async (): Promise<void> => {
|
||||
setSelectedTags([])
|
||||
await loadVoices(1, appliedQuery, false, [])
|
||||
}
|
||||
|
||||
const playPreview = (voice: TextToSpeechVoice): void => {
|
||||
if (!voice.previewUrl) {
|
||||
onNotice('这个音色暂时没有公开试听片段')
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
if (playingVoiceId === voice.id) {
|
||||
setPlayingVoiceId('')
|
||||
return
|
||||
}
|
||||
const audio = new Audio(voice.previewUrl)
|
||||
audioRef.current = audio
|
||||
setPlayingVoiceId(voice.id)
|
||||
audio.addEventListener('ended', () => setPlayingVoiceId(''), { once: true })
|
||||
audio.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
setPlayingVoiceId('')
|
||||
onNotice('音色试听加载失败')
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
void audio.play().catch(() => {
|
||||
setPlayingVoiceId('')
|
||||
onNotice('音色试听播放失败')
|
||||
})
|
||||
}
|
||||
|
||||
const openApiKeys = async (): Promise<void> => {
|
||||
const result = await window.api.openFishAudioApiKeys()
|
||||
if (!result.success) onNotice(result.error || '无法打开 API Key 页面')
|
||||
}
|
||||
|
||||
const refreshRuntime = async (): Promise<void> => {
|
||||
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
|
||||
}
|
||||
|
||||
const downloadRuntime = async (): Promise<void> => {
|
||||
if (runtimeBusy || !isMac || !runtimeStatus?.supported) return
|
||||
setRuntimeBusy(true)
|
||||
setRuntimeStatus((current) =>
|
||||
current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
|
||||
)
|
||||
try {
|
||||
const result = await window.api.downloadPersonalWechatRuntime()
|
||||
setRuntimeStatus(result.status)
|
||||
onNotice(result.success ? '微信发送组件已准备好' : result.error || '发送组件下载失败')
|
||||
} finally {
|
||||
setRuntimeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRuntimeDownload = async (): Promise<void> => {
|
||||
if (!personalWechatRuntimeSupported) return
|
||||
await window.api.cancelPersonalWechatRuntimeDownload()
|
||||
onNotice('正在取消发送组件下载')
|
||||
}
|
||||
|
||||
const removeRuntime = async (): Promise<void> => {
|
||||
if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return
|
||||
if (!window.confirm('卸载微信发送组件?以后需要向个人微信发送语音时可以重新下载。')) return
|
||||
setRuntimeBusy(true)
|
||||
try {
|
||||
setRuntimeStatus(await window.api.removePersonalWechatRuntime())
|
||||
onNotice('微信发送组件已卸载')
|
||||
} catch (reason) {
|
||||
onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败')
|
||||
} finally {
|
||||
setRuntimeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openRuntimeDirectory = async (): Promise<void> => {
|
||||
if (!personalWechatRuntimeSupported) return
|
||||
const result = await window.api.openPersonalWechatRuntimeDirectory()
|
||||
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
|
||||
}
|
||||
|
||||
const keyStatusText = !settings
|
||||
? '正在读取 API Key 状态'
|
||||
: settings.keySource === 'secure-storage'
|
||||
? 'Key 已保存在系统安全存储中,页面不会回显完整内容'
|
||||
: settings.keySource === 'environment'
|
||||
? '已从应用环境中读取 API Key'
|
||||
: settings.encryptionAvailable
|
||||
? '还没有配置 API Key,可在这里直接保存'
|
||||
: '当前系统安全存储不可用,请从应用环境中提供 API Key'
|
||||
|
||||
return (
|
||||
<div className="settings-page text-to-speech-page">
|
||||
<header className="settings-page-header">
|
||||
<div>
|
||||
<h1>文字转语音</h1>
|
||||
<p>选择喜欢的音色,把文字生成为可发送的微信语音</p>
|
||||
</div>
|
||||
<span className={`settings-status-badge ${settings?.hasApiKey ? '' : 'unavailable'}`}>
|
||||
{loading ? '读取中' : settings?.hasApiKey ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="settings-page-scroll">
|
||||
<div className="settings-page-content text-to-speech-content">
|
||||
<section className="tts-usage-guide">
|
||||
<div className="tts-usage-guide-heading">
|
||||
<div>
|
||||
<span className="tts-experimental-badge">实验性功能</span>
|
||||
<h2>使用说明</h2>
|
||||
</div>
|
||||
<p>语音生成与微信发送是两个独立步骤。建议先生成并试听,确认无误后再发送。</p>
|
||||
</div>
|
||||
|
||||
<ol className="tts-usage-steps">
|
||||
<li>
|
||||
<span>1</span>
|
||||
<div>
|
||||
<strong>配置并选择音色</strong>
|
||||
<p>保存 API Key,在下方音色库中选择一个音色。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>2</span>
|
||||
<div>
|
||||
<strong>在档案中生成语音</strong>
|
||||
<p>打开联系人或群聊的测试发送,选择“语音 → 输入文字生成”。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>3</span>
|
||||
<div>
|
||||
<strong>试听后手动发送</strong>
|
||||
<p>点击“生成语音”,播放检查内容,再点击发送到当前会话。</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="tts-hook-warning">
|
||||
<div className="tts-hook-warning-icon" aria-hidden>
|
||||
!
|
||||
</div>
|
||||
<div>
|
||||
<strong>微信发送能力注意事项</strong>
|
||||
<ul>
|
||||
<li>
|
||||
仅支持与当前 OneBot 运行时配置匹配的 macOS
|
||||
微信版本;微信自动更新后需要重新确认兼容性。
|
||||
</li>
|
||||
<li>
|
||||
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
|
||||
会降低系统安全性,请确认风险后再使用。
|
||||
</li>
|
||||
<li>微信重新登录或 PID 改变后,需要在测试发送弹窗中点击“尝试重新绑定”。</li>
|
||||
</ul>
|
||||
{personalWechatRuntimeSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tts-supported-versions-button"
|
||||
onClick={() => setShowWechatVersions(true)}
|
||||
>
|
||||
查看支持版本
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">微信发送组件</h2>
|
||||
<section className="settings-card tts-runtime-card">
|
||||
<div className="tts-runtime-summary">
|
||||
<span className="settings-card-kicker">
|
||||
OneBot {runtimeStatus?.version || 'v0.0.18'}
|
||||
</span>
|
||||
<strong>
|
||||
{!isMac
|
||||
? '暂不支持'
|
||||
: runtimeStatus?.state === 'downloading'
|
||||
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
|
||||
: runtimeStatus
|
||||
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
|
||||
: '正在检测'}
|
||||
</strong>
|
||||
<small>
|
||||
{isWindows
|
||||
? 'Windows 暂不支持个人微信发送;仍可生成和试听语音'
|
||||
: !isMac
|
||||
? '当前平台暂不支持个人微信发送'
|
||||
: !runtimeStatus
|
||||
? '正在检测当前平台与组件状态'
|
||||
: personalWechatRuntimeSupported
|
||||
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
|
||||
: '当前 Mac 环境不满足个人微信发送组件要求'}
|
||||
</small>
|
||||
{runtimeStatus?.error ? (
|
||||
<p className="tts-runtime-error">{runtimeStatus.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="tts-runtime-actions">
|
||||
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
|
||||
<button type="button" onClick={() => void cancelRuntimeDownload()}>
|
||||
取消下载
|
||||
</button>
|
||||
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
|
||||
<>
|
||||
{runtimeStatus.directory ? (
|
||||
<button type="button" onClick={() => void openRuntimeDirectory()}>
|
||||
打开目录
|
||||
</button>
|
||||
) : null}
|
||||
{runtimeStatus.removable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-danger-button"
|
||||
disabled={runtimeBusy}
|
||||
onClick={() => void removeRuntime()}
|
||||
>
|
||||
卸载组件
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : personalWechatRuntimeSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-primary-button"
|
||||
disabled={runtimeBusy}
|
||||
onClick={() => void downloadRuntime()}
|
||||
>
|
||||
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
|
||||
? '重新下载'
|
||||
: '下载组件'}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" disabled={runtimeBusy} onClick={() => void refreshRuntime()}>
|
||||
重新检测
|
||||
</button>
|
||||
{personalWechatRuntimeSupported ? (
|
||||
<button type="button" onClick={() => setShowWechatVersions(true)}>
|
||||
支持版本
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
|
||||
<div className="tts-runtime-progress">
|
||||
<div>
|
||||
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
|
||||
<small>
|
||||
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
|
||||
{formatBytes(runtimeStatus.totalBytes)}
|
||||
</small>
|
||||
</div>
|
||||
<progress
|
||||
value={runtimeStatus.progress}
|
||||
max={1}
|
||||
aria-label="微信发送组件下载进度"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">API 设置</h2>
|
||||
<section className="settings-card tts-api-card">
|
||||
<div className="tts-api-heading">
|
||||
<div>
|
||||
<span className="settings-card-kicker">语音服务</span>
|
||||
<strong>API Key</strong>
|
||||
<p>保存后即可加载音色并生成语音。</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void openApiKeys()}>
|
||||
前往 api.fish.audio 获取 Key
|
||||
</button>
|
||||
</div>
|
||||
<label className="tts-api-input">
|
||||
<span>API Key</span>
|
||||
<div>
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
disabled={savingKey || !settings?.encryptionAvailable}
|
||||
placeholder={
|
||||
settings?.hasStoredApiKey
|
||||
? '已安全保存;输入新 Key 可替换'
|
||||
: settings?.hasEnvironmentApiKey
|
||||
? '当前使用环境变量;也可保存一个应用专用 Key'
|
||||
: '粘贴 API Key'
|
||||
}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowApiKey((current) => !current)}>
|
||||
{showApiKey ? '隐藏' : '显示'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tts-save-key-button"
|
||||
disabled={!apiKey.trim() || savingKey || !settings?.encryptionAvailable}
|
||||
onClick={() => void saveApiKey()}
|
||||
>
|
||||
{savingKey ? '保存中…' : '保存 Key'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div className="tts-api-footer">
|
||||
<span>{keyStatusText}</span>
|
||||
{settings?.hasStoredApiKey ? (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-danger-button"
|
||||
onClick={() => void clearApiKey()}
|
||||
>
|
||||
清除应用内 Key
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="tts-model-select">
|
||||
<span>合成模型</span>
|
||||
<select
|
||||
value={settings?.model || 's2.1-pro-free'}
|
||||
disabled={!settings}
|
||||
onChange={(event) => void changeModel(event.target.value as TextToSpeechModel)}
|
||||
>
|
||||
<option value="s2.1-pro-free">s2.1-pro-free</option>
|
||||
<option value="s2.1-pro">s2.1-pro</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div className="tts-voice-heading">
|
||||
<div>
|
||||
<h2 className="settings-section-heading">选择音色</h2>
|
||||
<p>
|
||||
当前已加载 {voices.length} 个音色{total ? `,共找到 ${total} 个` : ''}。
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="tts-voice-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void searchVoices()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>⌕</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="按音色名称搜索"
|
||||
disabled={!settings?.hasApiKey || loadingVoices}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={!settings?.hasApiKey || loadingVoices}>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="tts-voice-filters" aria-label="音色筛选">
|
||||
<button
|
||||
type="button"
|
||||
className={!selectedTags.length ? 'active' : ''}
|
||||
disabled={!settings?.hasApiKey || loadingVoices}
|
||||
onClick={() => void clearVoiceFilters()}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{VOICE_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
className={selectedTags.includes(filter.value) ? 'active' : ''}
|
||||
disabled={!settings?.hasApiKey || loadingVoices}
|
||||
onClick={() => void toggleVoiceFilter(filter.value)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedVoice ? (
|
||||
<section className="tts-current-voice">
|
||||
<VoiceAvatar voice={selectedVoice} />
|
||||
<div>
|
||||
<span className="settings-card-kicker">当前音色</span>
|
||||
<strong>{selectedVoice.name}</strong>
|
||||
<p>
|
||||
{selectedVoice.authorName ? `${selectedVoice.authorName} · ` : ''}
|
||||
{selectedVoice.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="tts-current-tags">
|
||||
{voiceMetaTags(selectedVoice)
|
||||
.slice(0, 5)
|
||||
.map((tag) => (
|
||||
<span key={tag}>{voiceTagLabel(tag)}</span>
|
||||
))}
|
||||
{voiceMetaTags(selectedVoice).length > 5 ? (
|
||||
<span>+{voiceMetaTags(selectedVoice).length - 5}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!settings?.hasApiKey && !loading ? (
|
||||
<div className="settings-card tts-voice-empty">
|
||||
<strong>先配置 API Key</strong>
|
||||
<span>保存后即可加载并选择音色。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tts-voice-grid" role="radiogroup" aria-label="可用音色">
|
||||
{visibleVoices.map((voice) => {
|
||||
const selected = voice.id === selectedVoiceId
|
||||
return (
|
||||
<article
|
||||
key={voice.id}
|
||||
className={`tts-voice-card ${selected ? 'selected' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className="tts-voice-select"
|
||||
disabled={Boolean(savingVoiceId)}
|
||||
onClick={() => void selectVoice(voice)}
|
||||
>
|
||||
<VoiceAvatar voice={voice} />
|
||||
<span className="tts-voice-copy">
|
||||
<span className="tts-voice-title-row">
|
||||
<strong>{voice.name}</strong>
|
||||
{voice.authorName ? <em>{voice.authorName}</em> : null}
|
||||
</span>
|
||||
<small>{voice.description || '公开音色'}</small>
|
||||
<span className="tts-voice-tags">
|
||||
{voiceMetaTags(voice)
|
||||
.slice(0, 4)
|
||||
.map((tag) => (
|
||||
<span key={tag}>{voiceTagLabel(tag)}</span>
|
||||
))}
|
||||
{voiceMetaTags(voice).length > 4 ? (
|
||||
<span>+{voiceMetaTags(voice).length - 4}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="tts-voice-stats">
|
||||
<span title="使用量">▥ {compactCount(voice.taskCount)}</span>
|
||||
<span title="喜欢">♡ {compactCount(voice.likeCount)}</span>
|
||||
{voice.markCount ? (
|
||||
<span title="收藏">☆ {compactCount(voice.markCount)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tts-voice-check">
|
||||
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tts-voice-preview"
|
||||
disabled={!voice.previewUrl}
|
||||
onClick={() => playPreview(voice)}
|
||||
>
|
||||
{playingVoiceId === voice.id ? '停止' : '试听'}
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingVoices ? <div className="tts-voice-loading">正在加载音色…</div> : null}
|
||||
{!loadingVoices && settings?.hasApiKey && !voices.length ? (
|
||||
<div className="settings-card tts-voice-empty">
|
||||
{appliedQuery ? `没有找到“${appliedQuery}”相关音色` : '没有获取到可用音色'}
|
||||
</div>
|
||||
) : null}
|
||||
{hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tts-load-more"
|
||||
disabled={loadingVoices}
|
||||
onClick={() => void loadVoices(pageNumber + 1, appliedQuery, true, selectedTags)}
|
||||
>
|
||||
加载更多音色
|
||||
</button>
|
||||
) : null}
|
||||
{error ? <p className="tts-settings-error">{error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showWechatVersions ? (
|
||||
<div
|
||||
className="tts-version-modal-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={() => setShowWechatVersions(false)}
|
||||
>
|
||||
<section
|
||||
className="tts-version-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tts-version-modal-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="tts-version-modal-title">支持的微信版本</h2>
|
||||
</div>
|
||||
<div className="tts-version-modal-actions">
|
||||
<a href={WECHAT_VERSION_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
下载微信历史版本 ↗
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="tts-version-modal-close"
|
||||
onClick={() => setShowWechatVersions(false)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="tts-version-modal-intro">请安装下列完整版本之一。</p>
|
||||
|
||||
<div className="tts-version-grid">
|
||||
{BUNDLED_WECHAT_VERSIONS.map((version) => (
|
||||
<span key={version}>{version}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -129,6 +129,46 @@
|
||||
background: var(--wxex-bg-elevated);
|
||||
}
|
||||
|
||||
.chat-tool-button-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-tool-button-wrapper .chat-tool-button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-tool-button-wrapper::after {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: max-content;
|
||||
max-width: min(240px, calc(100vw - 24px));
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 6px;
|
||||
background: var(--wxex-text-primary);
|
||||
color: var(--wxex-bg-elevated);
|
||||
content: attr(title);
|
||||
font: 12px/17px var(--wxex-font);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: left;
|
||||
transform: translateY(-3px);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-tool-button-wrapper:hover::after,
|
||||
.chat-tool-button-wrapper:focus-visible::after,
|
||||
.chat-tool-button-wrapper:focus-within::after {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.chat-icon-button:hover,
|
||||
.chat-tool-button:hover {
|
||||
background: var(--wxex-brand-soft);
|
||||
@@ -153,6 +193,659 @@
|
||||
background: #5a60c6;
|
||||
}
|
||||
|
||||
.personal-wechat-send-backdrop {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(18, 29, 25, 0.42);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.personal-wechat-send-dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 620px);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 16px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
|
||||
padding: 22px;
|
||||
|
||||
> header,
|
||||
> footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 19px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
font: 13px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-kicker {
|
||||
color: var(--wxex-brand);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.personal-wechat-send-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
line-height: 28px;
|
||||
|
||||
&:hover {
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-device-note {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
border: 1px solid rgba(36, 122, 99, 0.2);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: rgba(36, 122, 99, 0.065);
|
||||
padding: 10px 12px;
|
||||
|
||||
> span {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 1px;
|
||||
color: var(--wxex-brand);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-target {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 10px;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: #f3f6f4;
|
||||
padding: 12px 14px;
|
||||
|
||||
span {
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
code {
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-status {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
padding: 12px;
|
||||
|
||||
&.ready {
|
||||
border-color: rgba(36, 122, 99, 0.28);
|
||||
background: rgba(36, 122, 99, 0.06);
|
||||
}
|
||||
|
||||
&.blocked {
|
||||
border-color: rgba(190, 96, 60, 0.24);
|
||||
background: rgba(190, 96, 60, 0.06);
|
||||
}
|
||||
|
||||
strong,
|
||||
p,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 3px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
small.error {
|
||||
color: #b15338;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-status-actions {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.personal-wechat-send-diagnostics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-border);
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin-bottom: 3px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font: 11px/1.45 var(--wxex-font);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-main);
|
||||
padding: 4px;
|
||||
|
||||
button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: 600 13px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
button.active {
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-text-muted);
|
||||
|
||||
.ready & {
|
||||
background: var(--wxex-success);
|
||||
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.12);
|
||||
}
|
||||
|
||||
.blocked & {
|
||||
background: #be603c;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-editor {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
||||
> span {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
outline: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-primary);
|
||||
padding: 10px 12px;
|
||||
font: 14px/1.6 var(--wxex-font);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--wxex-brand);
|
||||
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
> small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-image-picker {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
|
||||
> span {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
> button {
|
||||
border: 1px solid var(--wxex-brand);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: transparent;
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
padding: 7px 12px;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
> div,
|
||||
> small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-main);
|
||||
padding: 10px 12px;
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
small {
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
> small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-voice-composer {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-wechat-voice-source {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-main);
|
||||
padding: 4px;
|
||||
|
||||
button {
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: 600 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
button.active {
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-generated-voice {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-wechat-generated-voice-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
span,
|
||||
strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: 3px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
padding: 7px 10px;
|
||||
font: 11px/17px var(--wxex-font);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-tts-readiness {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: rgba(190, 96, 60, 0.08);
|
||||
padding: 10px 12px;
|
||||
|
||||
&.ready {
|
||||
background: rgba(36, 122, 99, 0.08);
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-generation-progress {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
border: 1px solid rgba(36, 122, 99, 0.2);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: rgba(36, 122, 99, 0.045);
|
||||
padding: 11px 12px;
|
||||
|
||||
> div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-generation-track,
|
||||
.personal-wechat-preview-track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(36, 122, 99, 0.12);
|
||||
|
||||
> span {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-generation-track > span {
|
||||
width: 38%;
|
||||
animation: personal-wechat-generation-slide 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes personal-wechat-generation-slide {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(285%);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-generated-result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid rgba(36, 122, 99, 0.3);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: rgba(36, 122, 99, 0.055);
|
||||
padding: 11px 12px;
|
||||
|
||||
audio {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-generated-result-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> span {
|
||||
color: var(--wxex-text-muted);
|
||||
font: 10px/16px var(--wxex-font);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.personal-wechat-preview-track {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-generated-result-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
|
||||
button {
|
||||
min-height: 32px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
padding: 0 11px;
|
||||
font: 11px/17px var(--wxex-font);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.personal-wechat-generated-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.personal-wechat-generated-result-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-send-note {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.personal-wechat-send-result {
|
||||
border-radius: var(--wxex-radius-md);
|
||||
padding: 9px 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
|
||||
&.success {
|
||||
background: rgba(36, 122, 99, 0.1);
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(190, 96, 60, 0.1);
|
||||
color: #a84e32;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-icon-button svg,
|
||||
.chat-tool-button svg,
|
||||
.chat-ai-button svg,
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
@use './archive';
|
||||
@use './settings-advanced';
|
||||
@use './settings-preferences';
|
||||
@use './settings-text-to-speech';
|
||||
@use './theme';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -288,10 +298,107 @@
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.report-toolbar-button-hint {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.report-toolbar-button-hint > button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.report-toolbar-button-hint::after {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: max-content;
|
||||
max-width: min(280px, calc(100vw - 24px));
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 6px;
|
||||
background: var(--wxex-text-primary);
|
||||
color: var(--wxex-bg-elevated);
|
||||
content: attr(title);
|
||||
font: 12px/17px var(--wxex-font);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: left;
|
||||
transform: translateY(-3px);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.report-toolbar-button-hint:hover::after,
|
||||
.report-toolbar-button-hint:focus-visible::after,
|
||||
.report-toolbar-button-hint:focus-within::after {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.report-more-menu {
|
||||
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 +640,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 +666,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 +715,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 +738,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 +966,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 +1103,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 +1124,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 +1226,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,903 @@
|
||||
.text-to-speech-content {
|
||||
width: min(100%, 1040px);
|
||||
}
|
||||
|
||||
.tts-usage-guide {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid rgba(190, 96, 60, 0.24);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: linear-gradient(135deg, rgba(190, 96, 60, 0.055), rgba(36, 122, 99, 0.025));
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.tts-usage-guide-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
> p {
|
||||
max-width: 520px;
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-experimental-badge {
|
||||
border-radius: 999px;
|
||||
background: rgba(190, 96, 60, 0.12);
|
||||
color: #aa5135;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tts-usage-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-elevated);
|
||||
padding: 11px;
|
||||
|
||||
> span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-hook-warning {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 11px;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: rgba(190, 96, 60, 0.085);
|
||||
padding: 12px 13px;
|
||||
|
||||
strong {
|
||||
color: #9c4a32;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 7px 0 0;
|
||||
padding-left: 17px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-hook-warning-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(190, 96, 60, 0.16);
|
||||
color: #a84e32;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tts-supported-versions-button {
|
||||
margin-top: 10px;
|
||||
border: 1px solid rgba(190, 96, 60, 0.3);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: #9c4a32;
|
||||
cursor: pointer;
|
||||
padding: 6px 11px;
|
||||
font: 11px/17px var(--wxex-font);
|
||||
}
|
||||
|
||||
.tts-version-modal-backdrop {
|
||||
position: fixed;
|
||||
z-index: 110;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(18, 29, 25, 0.46);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.tts-version-modal {
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 620px);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 16px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
|
||||
padding: 22px;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tts-version-modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
> a {
|
||||
border: 1px solid rgba(36, 122, 99, 0.28);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tts-version-modal-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: 22px/28px var(--wxex-font);
|
||||
}
|
||||
|
||||
.tts-version-modal-intro {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 19px;
|
||||
|
||||
code {
|
||||
display: inline;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-brand);
|
||||
padding: 2px 5px;
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-version-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
|
||||
span {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-secondary);
|
||||
padding: 8px 9px;
|
||||
font: 11px/17px var(--wxex-font);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.tts-runtime-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 18px 28px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.tts-runtime-summary {
|
||||
min-width: 0;
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 5px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-runtime-error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--wxex-danger, #c85a5a);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.tts-runtime-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
> button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
padding: 7px 12px;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
> .settings-primary-button {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-runtime-progress {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 7px;
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.tts-version-modal-warning {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: rgba(190, 96, 60, 0.08);
|
||||
padding: 12px 13px;
|
||||
|
||||
strong {
|
||||
color: #9c4a32;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-api-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tts-api-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 22px;
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 600px;
|
||||
margin: 6px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-api-heading > button,
|
||||
.tts-api-footer button,
|
||||
.tts-api-input button,
|
||||
.tts-voice-search button,
|
||||
.tts-load-more {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-api-input {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
> span {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-right: 0;
|
||||
border-radius: var(--wxex-radius-sm) 0 0 var(--wxex-radius-sm);
|
||||
outline: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-primary);
|
||||
padding: 10px 12px;
|
||||
font: 13px/20px var(--wxex-font);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--wxex-brand);
|
||||
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tts-save-key-button {
|
||||
border-radius: 0 var(--wxex-radius-sm) var(--wxex-radius-sm) 0;
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-api-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-model-select {
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 360px);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
> span {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
select {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
outline: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-primary);
|
||||
padding: 9px 11px;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
|
||||
.settings-section-heading {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-search {
|
||||
width: min(350px, 46%);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
padding: 3px 4px 3px 12px;
|
||||
color: var(--wxex-text-muted);
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-primary);
|
||||
padding: 7px 0;
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow-x: auto;
|
||||
margin-top: 13px;
|
||||
padding: 1px 1px 5px;
|
||||
scrollbar-width: thin;
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 5px 11px;
|
||||
font: 11px/17px var(--wxex-font);
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(36, 122, 99, 0.38);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgba(36, 122, 99, 0.5);
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tts-current-voice {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 15px;
|
||||
border: 1px solid rgba(36, 122, 99, 0.32);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: rgba(36, 122, 99, 0.055);
|
||||
padding: 13px 15px;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
overflow: hidden;
|
||||
margin: 4px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-current-tags,
|
||||
.tts-voice-tags,
|
||||
.tts-voice-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tts-current-tags {
|
||||
justify-content: flex-end;
|
||||
|
||||
span {
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-media,
|
||||
.tts-voice-avatar,
|
||||
.tts-voice-cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tts-voice-media {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tts-voice-cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
background: var(--wxex-bg-sidebar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tts-voice-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #d9eee6, #edf7f3);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tts-voice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 26px;
|
||||
row-gap: 2px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.tts-voice-card {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom-color: var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
padding: 12px 8px;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(36, 122, 99, 0.18);
|
||||
background: rgba(36, 122, 99, 0.035);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: rgba(36, 122, 99, 0.38);
|
||||
background: rgba(36, 122, 99, 0.065);
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-select {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tts-voice-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tts-voice-title-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-primary);
|
||||
font: 600 13px/18px var(--wxex-font);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
em {
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-muted);
|
||||
font: 10px/16px var(--wxex-font);
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-copy > small {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
min-height: 17px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font: 11px/17px var(--wxex-font);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.tts-voice-tags {
|
||||
min-height: 18px;
|
||||
|
||||
> span {
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
color: var(--wxex-text-muted);
|
||||
padding: 1px 6px;
|
||||
font: 9px/14px var(--wxex-font);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-stats {
|
||||
color: var(--wxex-text-muted);
|
||||
font: 9px/14px var(--wxex-font);
|
||||
|
||||
> span {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
|
||||
.selected & {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-preview {
|
||||
min-width: 42px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
padding: 5px 9px;
|
||||
font: 10px/16px var(--wxex-font);
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
color: var(--wxex-text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-empty {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 12px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tts-voice-loading,
|
||||
.tts-settings-error {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tts-voice-loading {
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.tts-settings-error {
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
|
||||
.tts-load-more {
|
||||
display: block;
|
||||
margin: 18px auto 0;
|
||||
border-radius: 999px;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.tts-usage-steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tts-voice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tts-api-heading,
|
||||
.tts-voice-heading,
|
||||
.tts-api-footer,
|
||||
.tts-usage-guide-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tts-usage-guide-heading > p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tts-api-input > div {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.tts-api-input .tts-save-key-button {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 7px;
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
}
|
||||
|
||||
.tts-voice-search {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.tts-current-voice {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.tts-current-tags {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.tts-model-select {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tts-version-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tts-runtime-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tts-runtime-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.tts-version-modal > header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tts-version-modal-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type RuntimePlatform = NodeJS.Platform | 'unknown'
|
||||
|
||||
function detectRuntimePlatform(): RuntimePlatform {
|
||||
if (typeof window === 'undefined') return 'unknown'
|
||||
return (window.electron?.process?.platform as NodeJS.Platform | undefined) || 'unknown'
|
||||
}
|
||||
|
||||
export const runtimePlatform = detectRuntimePlatform()
|
||||
export const isMac = runtimePlatform === 'darwin'
|
||||
export const isWindows = runtimePlatform === 'win32'
|
||||
|
||||
// Renderer entry points use this single capability flag. The main process still
|
||||
// performs the authoritative Apple Silicon and runtime checks before sending.
|
||||
export const supportsPersonalWechatSend = isMac
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ExportRequest {
|
||||
targets: ExportTarget[]
|
||||
format: ExportFormat
|
||||
outputName: string
|
||||
outputDirectory?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
kinds: ExportMessageKind[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export type PersonalWechatRuntimeState =
|
||||
| 'missing'
|
||||
| 'downloading'
|
||||
| 'ready'
|
||||
| 'invalid'
|
||||
| 'error'
|
||||
| 'unsupported'
|
||||
|
||||
export interface PersonalWechatRuntimeStatus {
|
||||
version: string
|
||||
state: PersonalWechatRuntimeState
|
||||
downloadedBytes: number
|
||||
totalBytes: number
|
||||
progress: number
|
||||
platform: NodeJS.Platform
|
||||
architecture: string
|
||||
supported: boolean
|
||||
removable: boolean
|
||||
directory?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PersonalWechatRuntimeDownloadResult {
|
||||
success: boolean
|
||||
status: PersonalWechatRuntimeStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PersonalWechatRuntimeProgressEvent extends PersonalWechatRuntimeStatus {}
|
||||
@@ -0,0 +1,84 @@
|
||||
export type PersonalWechatSenderState =
|
||||
| 'checking'
|
||||
| 'unsupported_platform'
|
||||
| 'wechat_not_running'
|
||||
| 'sip_enabled'
|
||||
| 'unsupported_version'
|
||||
| 'runtime_missing'
|
||||
| 'hook_not_ready'
|
||||
| 'stopped'
|
||||
| 'starting'
|
||||
| 'rebinding'
|
||||
| 'online'
|
||||
| 'error'
|
||||
|
||||
export interface PersonalWechatSenderStatus {
|
||||
state: PersonalWechatSenderState
|
||||
platform: string
|
||||
arch: string
|
||||
sipDisabled: boolean
|
||||
wechatRunning: boolean
|
||||
wechatPid?: number
|
||||
boundWechatPid?: number
|
||||
oneBotPid?: number
|
||||
endpoint: string
|
||||
endpointReady: boolean
|
||||
wechatVersion?: string
|
||||
runtimeReady: boolean
|
||||
executablePath?: string
|
||||
configPath?: string
|
||||
imagePath?: string
|
||||
attachReady: boolean
|
||||
baseAddress?: string
|
||||
baseAddressReady: boolean
|
||||
textHookInstalled: boolean
|
||||
textHookReady: boolean
|
||||
imageHookInstalled: boolean
|
||||
imageHookReady: boolean
|
||||
messageListenerReady: boolean
|
||||
canSend: boolean
|
||||
canSendText: boolean
|
||||
canSendImage: boolean
|
||||
canSendVoice: boolean
|
||||
message: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface PersonalWechatSendBaseRequest {
|
||||
to: string
|
||||
isGroup: boolean
|
||||
}
|
||||
|
||||
export interface PersonalWechatSendTextRequest extends PersonalWechatSendBaseRequest {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRequest {
|
||||
type: 'image'
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest {
|
||||
type: 'voice'
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export type PersonalWechatSendRequest =
|
||||
| PersonalWechatSendTextRequest
|
||||
| PersonalWechatSendImageRequest
|
||||
| PersonalWechatSendVoiceRequest
|
||||
|
||||
export interface PersonalWechatSendResult {
|
||||
success: boolean
|
||||
status: PersonalWechatSenderStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PersonalWechatImageSelectionResult {
|
||||
canceled: boolean
|
||||
path?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type PersonalWechatVoiceSelectionResult = PersonalWechatImageSelectionResult
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,75 @@
|
||||
export type TextToSpeechKeySource = 'secure-storage' | 'environment' | 'missing'
|
||||
export type TextToSpeechModel = 's2.1-pro-free' | 's2.1-pro'
|
||||
|
||||
export interface TextToSpeechVoice {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
languages: string[]
|
||||
source: 'fish-audio'
|
||||
coverImage?: string
|
||||
previewUrl?: string
|
||||
previewText?: string
|
||||
authorName?: string
|
||||
taskCount?: number
|
||||
likeCount?: number
|
||||
markCount?: number
|
||||
}
|
||||
|
||||
export interface TextToSpeechSettings {
|
||||
provider: 'fish-audio'
|
||||
hasApiKey: boolean
|
||||
hasStoredApiKey: boolean
|
||||
hasEnvironmentApiKey: boolean
|
||||
keySource: TextToSpeechKeySource
|
||||
encryptionAvailable: boolean
|
||||
selectedVoiceId: string
|
||||
outputFormat: 'mp3'
|
||||
model: TextToSpeechModel
|
||||
phase: 'ready'
|
||||
}
|
||||
|
||||
export interface TextToSpeechSettingsResult {
|
||||
success: boolean
|
||||
settings: TextToSpeechSettings
|
||||
voices: TextToSpeechVoice[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SaveTextToSpeechSettingsRequest {
|
||||
apiKey?: string
|
||||
clearApiKey?: boolean
|
||||
selectedVoiceId?: string
|
||||
model?: TextToSpeechModel
|
||||
}
|
||||
|
||||
export interface ListTextToSpeechVoicesRequest {
|
||||
pageNumber?: number
|
||||
pageSize?: number
|
||||
title?: string
|
||||
language?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface ListTextToSpeechVoicesResult {
|
||||
success: boolean
|
||||
items: TextToSpeechVoice[]
|
||||
total: number
|
||||
pageNumber: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SynthesizeTextToSpeechRequest {
|
||||
text: string
|
||||
referenceId: string
|
||||
}
|
||||
|
||||
export interface SynthesizeTextToSpeechResult {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
audioDataUrl?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
||||
|
||||
const getStatus = vi.fn()
|
||||
const rebind = vi.fn()
|
||||
const selectImage = vi.fn()
|
||||
const selectVoice = vi.fn()
|
||||
const sendMessage = vi.fn()
|
||||
const getTextToSpeechSettings = vi.fn()
|
||||
const listTextToSpeechVoices = vi.fn()
|
||||
const synthesizeTextToSpeech = vi.fn()
|
||||
const removeGeneratedTextToSpeechAudio = vi.fn()
|
||||
|
||||
const contact = {
|
||||
m_nsUsrName: 'fixture-room@chatroom',
|
||||
m_nsNickName: '技术交流群',
|
||||
md5: 'fixture-md5',
|
||||
type: 'group' as const
|
||||
}
|
||||
|
||||
const readyStatus = {
|
||||
state: 'online' as const,
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
wechatPid: 4668,
|
||||
boundWechatPid: 4668,
|
||||
oneBotPid: 5401,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: true,
|
||||
wechatVersion: '4.1.11.53',
|
||||
runtimeReady: true,
|
||||
attachReady: true,
|
||||
baseAddress: '0x114ef8000',
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: true,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: true,
|
||||
messageListenerReady: true,
|
||||
canSend: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
canSendVoice: true,
|
||||
message: '个人微信已绑定'
|
||||
}
|
||||
|
||||
describe('PersonalWechatSendDialog', () => {
|
||||
beforeEach(() => {
|
||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||
selectImage.mockReset().mockResolvedValue({
|
||||
canceled: false,
|
||||
path: '/Users/fixture/test.png',
|
||||
name: 'test.png'
|
||||
})
|
||||
selectVoice.mockReset().mockResolvedValue({
|
||||
canceled: false,
|
||||
path: '/Users/fixture/test.silk',
|
||||
name: 'test.silk'
|
||||
})
|
||||
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
|
||||
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
settings: {
|
||||
provider: 'fish-audio',
|
||||
hasApiKey: true,
|
||||
encryptionAvailable: true,
|
||||
selectedVoiceId: 'fish-warm-female',
|
||||
outputFormat: 'mp3',
|
||||
model: 's2.1-pro-free',
|
||||
phase: 'ready'
|
||||
},
|
||||
voices: []
|
||||
})
|
||||
listTextToSpeechVoices.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
items: [
|
||||
{
|
||||
id: 'fish-warm-female',
|
||||
name: '暖阳女声',
|
||||
description: '自然温和',
|
||||
tags: ['女声'],
|
||||
languages: ['中文'],
|
||||
source: 'fish-audio'
|
||||
}
|
||||
],
|
||||
total: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 24,
|
||||
hasMore: false
|
||||
})
|
||||
synthesizeTextToSpeech.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
filePath: '/tmp/generated.mp3',
|
||||
audioDataUrl: 'data:audio/mpeg;base64,fixture'
|
||||
})
|
||||
removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true })
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPersonalWechatSenderStatus: getStatus,
|
||||
rebindPersonalWechatSender: rebind,
|
||||
selectPersonalWechatImage: selectImage,
|
||||
selectPersonalWechatVoice: selectVoice,
|
||||
sendPersonalWechatMessage: sendMessage,
|
||||
getTextToSpeechSettings,
|
||||
listTextToSpeechVoices,
|
||||
synthesizeTextToSpeech,
|
||||
removeGeneratedTextToSpeechAudio
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('switches to voice mode and sends only the selected audio file', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
|
||||
expect(await screen.findByText('test.silk')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试发送语音到群聊' }))
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'voice',
|
||||
to: 'fixture-room@chatroom',
|
||||
filePath: '/Users/fixture/test.silk',
|
||||
isGroup: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the selected TTS voice and keeps generation separate from sending', async () => {
|
||||
const openSettings = vi.fn()
|
||||
render(
|
||||
<PersonalWechatSendDialog
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
onClose={vi.fn()}
|
||||
onOpenTextToSpeechSettings={openSettings}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||
expect(await screen.findByText('暖阳女声')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toHaveValue('1')
|
||||
expect(screen.getByRole('button', { name: '生成语音' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往文字转语音设置' }))
|
||||
expect(openSettings).toHaveBeenCalledTimes(1)
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows binding diagnostics and only offers image and voice modes', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('PID 4668')).toBeInTheDocument()
|
||||
expect(screen.getByText('PID 5401 · 绑定 4668')).toBeInTheDocument()
|
||||
expect(screen.getByText('0x114ef8000')).toBeInTheDocument()
|
||||
expect(screen.getByText('已捕获,可发送')).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('radio', { name: '语音' })).toBeVisible()
|
||||
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to image mode and sends only the selected image', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。',
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
|
||||
expect(await screen.findByText('test.png')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
to: 'fixture-room@chatroom',
|
||||
filePath: '/Users/fixture/test.png',
|
||||
isGroup: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('offers a safe explicit rebind action', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('button', { name: '尝试重新绑定' }))
|
||||
await waitFor(() => expect(rebind).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('blocks image sending until the media Hook and image directory are initialized', async () => {
|
||||
getStatus.mockResolvedValue({
|
||||
...readyStatus,
|
||||
state: 'hook_not_ready',
|
||||
imageHookReady: false,
|
||||
canSendImage: false,
|
||||
message: '请先手动发送图片'
|
||||
})
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -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,45 @@ describe('daily report controls', () => {
|
||||
value: {
|
||||
getAppLogPath: vi.fn(async () => ''),
|
||||
revealAppLog: vi.fn(async () => undefined),
|
||||
getPersonalWechatSenderStatus: vi.fn(async () => ({
|
||||
state: 'online',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
runtimeReady: true,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: true,
|
||||
attachReady: true,
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: true,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: true,
|
||||
messageListenerReady: true,
|
||||
canSend: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
canSendVoice: true,
|
||||
message: '个人微信已绑定'
|
||||
})),
|
||||
getTextToSpeechSettings: vi.fn(async () => ({
|
||||
success: true,
|
||||
settings: {
|
||||
provider: 'fish-audio',
|
||||
hasApiKey: false,
|
||||
hasStoredApiKey: false,
|
||||
hasEnvironmentApiKey: false,
|
||||
keySource: 'missing',
|
||||
encryptionAvailable: true,
|
||||
selectedVoiceId: '',
|
||||
outputFormat: 'mp3',
|
||||
model: 's2.1-pro-free',
|
||||
phase: 'ready'
|
||||
},
|
||||
voices: []
|
||||
})),
|
||||
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
|
||||
getGroupSnapshot: vi.fn(async () => ({
|
||||
members: [
|
||||
{
|
||||
@@ -50,7 +112,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 +131,506 @@ 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 the file action 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.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||
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('opens the current group send dialog with the report PNG preselected', async () => {
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-send',
|
||||
contactId: groupContact.md5,
|
||||
contactName: groupContact.m_nsNickName,
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||
reportDate: '2026-08-17',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture',
|
||||
pngPath: '/Users/fixture/测试群日报.png'
|
||||
}
|
||||
|
||||
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 }))}
|
||||
sendTarget={groupContact}
|
||||
personalWechatSendSupported
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
||||
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
|
||||
})
|
||||
|
||||
it('disables current group sending outside macOS with a readable hover hint', () => {
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-send-disabled',
|
||||
contactId: groupContact.md5,
|
||||
contactName: groupContact.m_nsNickName,
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||
reportDate: '2026-08-17',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture',
|
||||
pngPath: '/Users/fixture/测试群日报.png'
|
||||
}
|
||||
|
||||
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 }))}
|
||||
sendTarget={groupContact}
|
||||
personalWechatSendSupported={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: '发送到当前群聊' })
|
||||
expect(button).toBeDisabled()
|
||||
expect(button.parentElement).toHaveAttribute('title', '仅支持 macOS')
|
||||
})
|
||||
|
||||
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 +639,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('手机端 · 375–414 px')).toBeVisible()
|
||||
expect(screen.getByText('电脑端 · 1280–1920 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')
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user