Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77adc744e0 | ||
|
|
0adb064681 | ||
|
|
8e40487e08 | ||
|
|
d0ae9e6019 | ||
|
|
49684f3365 | ||
|
|
33aaf4d558 | ||
|
|
5ccabb3898 | ||
|
|
7db845ac7e | ||
|
|
9348c5ce4a | ||
|
|
f626d89a1a | ||
|
|
95e517eca5 | ||
|
|
e93e4554d7 | ||
|
|
9ae0c6cc47 | ||
|
|
59e609b08a | ||
|
|
9de7dcde3b | ||
|
|
cb3c2855c0 | ||
|
|
dca88e5db3 | ||
|
|
1a9f27488b | ||
|
|
3b64e18b5e | ||
|
|
554dccdb21 | ||
|
|
c674dcbc4a | ||
|
|
0f65b96da1 | ||
|
|
8488e81bb5 | ||
|
|
430a36333b | ||
|
|
474250c6c7 | ||
|
|
794880d389 | ||
|
|
d0ceeceb68 | ||
|
|
62f729d281 | ||
|
|
0d544275c0 | ||
|
|
a91b4d7a56 | ||
|
|
f9b567fba2 | ||
|
|
1f693aa3d7 | ||
|
|
8e1e166856 | ||
|
|
1275c512c4 | ||
|
|
1abaa57a0c | ||
|
|
d08db46479 | ||
|
|
eaea8d8435 | ||
|
|
1e97953d67 | ||
|
|
597667e005 | ||
|
|
515348b6d8 | ||
|
|
e150605c91 | ||
|
|
556d70eab3 | ||
|
|
d079d45279 |
@@ -6,6 +6,9 @@ VITE_DB_KEY=
|
|||||||
VITE_AUTO_LOGIN=false
|
VITE_AUTO_LOGIN=false
|
||||||
|
|
||||||
# AI API Configuration (Optional, can be entered in UI)
|
# AI API Configuration (Optional, can be entered in UI)
|
||||||
|
# 注意:发布版本不再自动读取以下环境变量。
|
||||||
|
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||||
|
# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。
|
||||||
VITE_DEEPSEEK_API_KEY=
|
VITE_DEEPSEEK_API_KEY=
|
||||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
VITE_AI_BASE_URL=https://api.deepseek.com
|
||||||
VITE_AI_MODEL=deepseek-chat
|
VITE_AI_MODEL=deepseek-chat
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
*.tsbuildinfo
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
resources/connectors/wechat/
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
docs/design/
|
docs/design/
|
||||||
docs/ui-redesign-plan.md
|
docs/ui-redesign-plan.md
|
||||||
docs/ui-redesign-spec.md
|
docs/ui-redesign-spec.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
findings.md
|
||||||
|
progress.md
|
||||||
|
task_plan.md
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# WechatExplorer
|
# WechatExplorer
|
||||||
|
|
||||||
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
macOS / Windows 微信聊天记录查看与 AI 分析工具。
|
||||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
是一个基于 Electron + React + TypeScript 开发的本地微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供 AI 智能检索、群聊总结和多格式聊天记录导出功能。
|
||||||
|
|
||||||
## 项目说明
|
## 项目说明
|
||||||
|
|
||||||
@@ -9,23 +9,28 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
|
|
||||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||||
|
|
||||||
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||||
- **全局搜索**: 快速搜索聊天内容。
|
- **AI 智能检索**: 支持全局搜索、指定会话搜索和自然语言提问,帮助快速定位聊天主题与相关证据。
|
||||||
|
- **消息防撤回**: 可在设置中开启,归档并高亮查看对方已撤回消息。
|
||||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||||
|
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||||
- **问题与解答**: 整理群内的问答内容。
|
- **问题与解答**: 整理群内的问答内容。
|
||||||
|
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||||
|
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||||
- **词云/关键词**: 可视化展示群聊关键词。
|
- **词云/关键词**: 可视化展示群聊关键词。
|
||||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
- **数据导出**: 支持将聊天记录导出为 HTML、CSV、JSON 和 Markdown,支持按时间范围导出并打开文件所在文件夹。
|
||||||
|
- **诊断日志**: 可在设置的高级选项中控制诊断日志,方便排查运行异常。
|
||||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||||
|
|
||||||
## 📸 预览
|
## 📸 预览
|
||||||
@@ -42,48 +47,31 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
|
|
||||||
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
||||||
|
|
||||||
|
### 检索界面
|
||||||
|
|
||||||
|
<img src="./public/ai-search.png" alt="检索页面" />
|
||||||
|
|
||||||
### 本地 API 与 Reader Skill
|
### 本地 API 与 Reader Skill
|
||||||
|
|
||||||
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
||||||
|
|
||||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||||
|
|
||||||
## 📦 安装说明
|
## 📖 使用方法
|
||||||
|
|
||||||
### macOS 版本已测 4.1.8
|
安装、获取数据库密钥、连接微信数据及常见问题,请查看:
|
||||||
|
|
||||||
> 微信 macOS `4.1.8.100` 下载地址:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
### [👉 WechatExplorer 完整使用教程](./docs/user-guide/getting-started.md)
|
||||||
|
|
||||||
1. 下载 Releases 中的 `xxx.dmg` 文件。
|
教程包含 macOS 与 Windows 的分步截图,以及数据目录、SIP、图片解密密钥和自动获取失败的排查方法。
|
||||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
|
||||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
|
||||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
|
||||||
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
|
|
||||||
|
|
||||||
### Windows 版本已测 4.1.9
|
> 微信 4.0+ 在 macOS / Windows 上已支持部分能力,目前仍在持续适配。如需其他成熟方案,也可参考 [WeFlow](https://github.com/hicccc77/WeFlow) 和 [Chatlog](https://github.com/sjzar/chatlog)。
|
||||||
|
|
||||||
> 微信 Windows `4.1.9.57` 下载地址:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
## 🛠️ 开发配置(可选)
|
||||||
|
|
||||||
1. 下载 Releases 中的 `xxx-setup.exe` 安装包。
|
本地开发需要 Node.js(推荐 v16+)和 pnpm 7。
|
||||||
2. 双击安装,可按安装向导选择安装目录。
|
|
||||||
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
|
|
||||||
|
|
||||||
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
|
### 环境变量
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 使用前置要求
|
|
||||||
|
|
||||||
- **微信版本**:
|
|
||||||
- 微信 4.0+: macOS / Windows 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
|
||||||
- **macOS 自动获取密钥**: 需要先关闭 SIP,操作方式见 [macOS 关闭 SIP 教程](./docs/mac-disable-sip.md);关闭 SIP 会降低系统安全性,使用完成后建议重新开启。
|
|
||||||
- 如无法获取本地数据库密码,则无法使用当前项目
|
|
||||||
- Node.js (推荐 v16+)
|
|
||||||
- pnpm@7
|
|
||||||
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
|
|
||||||
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
|
|
||||||
|
|
||||||
### 环境变量配置 (.env)
|
|
||||||
|
|
||||||
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||||
|
|
||||||
@@ -97,20 +85,6 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||||
|
|
||||||
#### 图片解密密钥说明
|
|
||||||
|
|
||||||
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
|
|
||||||
|
|
||||||
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
|
|
||||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
|
||||||
|
|
||||||
这两个密钥可以通过以下方式获取:
|
|
||||||
|
|
||||||
1. 在应用登录界面点击“自动获取密钥”
|
|
||||||
2. 从 WeFlow/Chatlog 设置中导出
|
|
||||||
3. 在软件“设置 -> 图片解密密钥”中自动获取或手动填写
|
|
||||||
4. 如自动获取失败,可手动粘贴已获取的密钥
|
|
||||||
|
|
||||||
## 🤖 AI 集成(本地 HTTP API)
|
## 🤖 AI 集成(本地 HTTP API)
|
||||||
|
|
||||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||||
@@ -189,8 +163,24 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
|||||||
|
|
||||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&theme=dark&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
## 🔗 参考致谢
|
## 🔗 参考致谢
|
||||||
|
|
||||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||||
- [chatlog](https://github.com/sjzar/chatlog)
|
- [chatlog](https://github.com/sjzar/chatlog)
|
||||||
|
|
||||||
|
## 📱 交流与反馈
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
|
|||||||
"qa": [
|
"qa": [
|
||||||
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
|
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
|
||||||
],
|
],
|
||||||
|
"unresolved": [
|
||||||
|
{ "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
|
||||||
|
],
|
||||||
|
"storylines": [
|
||||||
|
{ "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
|
||||||
|
],
|
||||||
|
"reversals": [
|
||||||
|
{ "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
|
||||||
|
],
|
||||||
|
"participantChains": [
|
||||||
|
{ "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
|
||||||
|
],
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
||||||
"activeTimeline": "10:00-12:00 为最活跃时段",
|
"activeTimeline": "10:00-12:00 为最活跃时段",
|
||||||
"topSpeakers": [{ "name": "张三", "count": 58 }]
|
"topSpeakers": [{ "name": "张三", "count": 58 }],
|
||||||
|
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
|
||||||
},
|
},
|
||||||
"keywords": ["高频词1", "高频词2"]
|
"keywords": ["高频词1", "高频词2"],
|
||||||
|
"hero": {
|
||||||
|
"headline": "一句抓重点的日报标题",
|
||||||
|
"summary": "一句概览",
|
||||||
|
"keyTakeaway": "最重要结论",
|
||||||
|
"pendingNote": "待跟进事项"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"groupName": "技术交流",
|
"groupName": "技术交流",
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# WechatExplorer 使用教程
|
||||||
|
|
||||||
|
本文介绍如何安装 WechatExplorer、自动获取微信数据库密钥,并完成首次连接。
|
||||||
|
|
||||||
|
## 1. 使用前准备
|
||||||
|
|
||||||
|
### 支持的版本
|
||||||
|
|
||||||
|
| 系统 | 已测试的微信版本 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| macOS | `4.1.8.100` | 支持相对稳定;自动获取密钥前需要关闭 SIP |
|
||||||
|
| Windows | `4.1.9.57` | 已初步支持;不同安装路径和数据目录可能仍需手动调整 |
|
||||||
|
|
||||||
|
- macOS 微信下载:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
||||||
|
- Windows 微信下载:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
||||||
|
- WechatExplorer 下载:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> WechatExplorer 必须取得本机微信数据库密钥才能读取聊天记录。请仅处理你有权访问的数据。
|
||||||
|
|
||||||
|
### macOS:关闭 SIP
|
||||||
|
|
||||||
|
macOS 自动获取密钥前需要关闭 SIP,具体操作见 [macOS 关闭 SIP 教程](../mac-disable-sip.md)。
|
||||||
|
|
||||||
|
关闭 SIP 会降低系统安全性。建议了解风险后再操作,并在不再需要自动获取密钥时重新开启。
|
||||||
|
|
||||||
|
## 2. 安装 WechatExplorer
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
1. 从 Releases 下载 `.dmg` 文件。
|
||||||
|
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
|
||||||
|
3. 如果系统提示“无法打开,因为开发者无法验证”,请前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||||
|
4. 如果系统提示应用已损坏,在终端执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -cr "/Applications/WechatExplorer.app"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
1. 从 Releases 下载 `-setup.exe` 安装包。
|
||||||
|
2. 双击安装,并按安装向导完成操作。
|
||||||
|
|
||||||
|
## 3. 自动获取密钥
|
||||||
|
|
||||||
|
### 第一步:确认微信数据目录
|
||||||
|
|
||||||
|
启动 WechatExplorer 后,先检查页面中的“存储路径”是否正确。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 第二步:让微信停留在登录页面
|
||||||
|
|
||||||
|
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 第三步:开始获取密钥
|
||||||
|
|
||||||
|
返回 WechatExplorer,点击“自动获取密钥”。
|
||||||
|
|
||||||
|
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
|
||||||
|
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
|
||||||
|
|
||||||
|
### 第四步:完成连接
|
||||||
|
|
||||||
|
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 4. 图片解密密钥
|
||||||
|
|
||||||
|
微信 4.0 及以上版本的图片通常以 `.dat` 文件存储,显示图片还需要:
|
||||||
|
|
||||||
|
- **XOR Key**:单字节十六进制值,例如 `0x40`。
|
||||||
|
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
|
||||||
|
|
||||||
|
可以通过以下方式配置:
|
||||||
|
|
||||||
|
1. 使用首次连接页面的“自动获取密钥”。
|
||||||
|
2. 在“设置 → 图片解密密钥”中自动获取或手动填写。
|
||||||
|
3. 从 WeFlow 或 Chatlog 的设置中导出后手动填写。
|
||||||
|
|
||||||
|
数据库连接成功但图片无法显示时,请优先检查这两项密钥。
|
||||||
|
|
||||||
|
## 5. 常见问题
|
||||||
|
|
||||||
|
### 自动获取密钥失败
|
||||||
|
|
||||||
|
请依次确认:
|
||||||
|
|
||||||
|
1. 微信版本是否与上方已测试版本一致。
|
||||||
|
2. 点击“自动获取密钥”时,微信是否停留在未登录页面。
|
||||||
|
3. 微信数据目录是否正确;Windows 用户尤其需要检查是否多选或少选了一层目录。
|
||||||
|
4. macOS 是否已按教程关闭 SIP,并完成系统授权。
|
||||||
|
5. 微信和 WechatExplorer 是否都保持运行。
|
||||||
|
|
||||||
|
仍然失败时,可以切换到“手动输入”,粘贴从其他兼容工具中取得的数据库密钥。
|
||||||
|
|
||||||
|
### Windows 使用时卡顿
|
||||||
|
|
||||||
|
Windows 支持仍处于初步阶段,不同微信版本、安装路径、数据目录和权限环境可能存在差异。建议优先使用上方已测试的微信版本。
|
||||||
|
|
||||||
|
### 数据会上传吗?
|
||||||
|
|
||||||
|
聊天数据库在本机读取和处理。只有使用 AI 总结功能时,相关聊天内容才会按你配置的模型服务发送;是否启用以及使用哪个服务由你决定。
|
||||||
|
|
||||||
|
## 6. 下一步
|
||||||
|
|
||||||
|
- 在应用“设置”中填写兼容 OpenAI API 的模型服务和 API Key,使用 AI 总结功能。
|
||||||
|
- 在应用的 **API** 页面安装 Reader Skill,让 Codex 或 Claude Code 读取和总结本地群聊。
|
||||||
|
- 本地 API 的端点和调试方法见项目 [README](../../README.md#ai-集成本地-http-api)。
|
||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 214 KiB |
@@ -16,6 +16,7 @@ extraMetadata:
|
|||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
extraResources:
|
extraResources:
|
||||||
|
# Includes the optional WeChat connector binary for the target platform.
|
||||||
- from: resources
|
- from: resources
|
||||||
to: resources
|
to: resources
|
||||||
filter:
|
filter:
|
||||||
@@ -67,4 +68,4 @@ publish:
|
|||||||
provider: github
|
provider: github
|
||||||
owner: Wxw-Gu
|
owner: Wxw-Gu
|
||||||
repo: WechatExplorer
|
repo: WechatExplorer
|
||||||
releaseType: draft
|
releaseType: release
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use strict";
|
||||||
|
const electron = require("electron");
|
||||||
|
const preload = require("@electron-toolkit/preload");
|
||||||
|
const api = {
|
||||||
|
writeAppLog: (entry) => electron.ipcRenderer.invoke("app-log:write", entry),
|
||||||
|
getAppLogPath: () => electron.ipcRenderer.invoke("app-log:getPath"),
|
||||||
|
revealAppLog: () => electron.ipcRenderer.invoke("app-log:reveal"),
|
||||||
|
getAppUpdateState: () => electron.ipcRenderer.invoke("app-update:getState"),
|
||||||
|
checkAppUpdate: () => electron.ipcRenderer.invoke("app-update:check"),
|
||||||
|
downloadAppUpdate: () => electron.ipcRenderer.invoke("app-update:download"),
|
||||||
|
installAppUpdate: () => electron.ipcRenderer.invoke("app-update:install"),
|
||||||
|
onAppUpdateState: (callback) => {
|
||||||
|
const listener = (_event, state) => callback(state);
|
||||||
|
electron.ipcRenderer.on("app-update:state", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("app-update:state", listener);
|
||||||
|
},
|
||||||
|
getCacheSummary: () => electron.ipcRenderer.invoke("cache:getSummary"),
|
||||||
|
clearCache: (scope) => electron.ipcRenderer.invoke("cache:clear", scope),
|
||||||
|
initDb: (key) => electron.ipcRenderer.invoke("db:init", key),
|
||||||
|
getBootstrapCache: () => electron.ipcRenderer.invoke("db:getBootstrapCache"),
|
||||||
|
getStartupCache: () => electron.ipcRenderer.invoke("db:getStartupCache"),
|
||||||
|
getContacts: (filter) => electron.ipcRenderer.invoke("db:getContacts", filter),
|
||||||
|
getContactAvatars: (usernames) => electron.ipcRenderer.invoke("db:getContactAvatars", usernames),
|
||||||
|
getCachedMessages: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessages", userMd5, startTime, endTime),
|
||||||
|
getCachedMessagePage: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessagePage", userMd5, startTime, endTime),
|
||||||
|
getMessages: (userMd5, startTime, endTime, options) => electron.ipcRenderer.invoke("db:getMessages", userMd5, startTime, endTime, options),
|
||||||
|
getGroupSnapshot: (userMd5) => electron.ipcRenderer.invoke("db:getGroupSnapshot", userMd5),
|
||||||
|
search: (keyword) => electron.ipcRenderer.invoke("db:search", keyword),
|
||||||
|
aiChat: (messages, options) => electron.ipcRenderer.invoke("ai:chat", messages, options),
|
||||||
|
listAIProviders: () => electron.ipcRenderer.invoke("ai:listProviders"),
|
||||||
|
getAIRuntimeConfig: () => electron.ipcRenderer.invoke("ai:getRuntimeConfig"),
|
||||||
|
saveAIProvider: (provider) => electron.ipcRenderer.invoke("ai:saveProvider", provider),
|
||||||
|
deleteAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:deleteProvider", providerId),
|
||||||
|
setDefaultAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:setDefaultProvider", providerId),
|
||||||
|
testAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:testProvider", providerId),
|
||||||
|
testAIVision: (request) => electron.ipcRenderer.invoke("ai:testVision", request),
|
||||||
|
migrateLegacyAIConfig: (config) => electron.ipcRenderer.invoke("ai:migrateLegacy", config),
|
||||||
|
copyImage: (base64String) => electron.ipcRenderer.invoke("copy-image", base64String),
|
||||||
|
getVoiceData: (sessionId, localId, createTime, svrId) => electron.ipcRenderer.invoke("db:getVoiceData", sessionId, localId, createTime, svrId),
|
||||||
|
parseMessage: (content, messageType) => electron.ipcRenderer.invoke("db:parseMessage", content, messageType),
|
||||||
|
getImage: (imageMd5, imageDatNameOrThumb, sessionId, options) => electron.ipcRenderer.invoke("db:getImage", imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
|
getVideo: (hashes) => electron.ipcRenderer.invoke("db:getVideo", hashes),
|
||||||
|
getSticker: (cdnUrl, md5) => electron.ipcRenderer.invoke("db:getSticker", cdnUrl, md5),
|
||||||
|
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
|
||||||
|
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
|
||||||
|
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
|
||||||
|
onExportProgress: (callback) => {
|
||||||
|
const listener = (_event, progress) => callback(progress);
|
||||||
|
electron.ipcRenderer.on("export:progress", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("export:progress", listener);
|
||||||
|
},
|
||||||
|
exportGroupReport: (request) => electron.ipcRenderer.invoke("report:export", request),
|
||||||
|
listGeneratedReports: () => electron.ipcRenderer.invoke("report:listGenerated"),
|
||||||
|
saveGeneratedReport: (request) => electron.ipcRenderer.invoke("report:saveGenerated", request),
|
||||||
|
deleteGeneratedReport: (reportId) => electron.ipcRenderer.invoke("report:deleteGenerated", reportId),
|
||||||
|
revealGroupReport: (filePath) => electron.ipcRenderer.invoke("report:reveal", filePath),
|
||||||
|
getSavedDbKey: () => electron.ipcRenderer.invoke("key:getSavedDbKey"),
|
||||||
|
getDatabaseKeyEnvironment: () => electron.ipcRenderer.invoke("key:getEnvironment"),
|
||||||
|
readDatabaseKeyClipboard: () => electron.ipcRenderer.invoke("key:readClipboardDbKey"),
|
||||||
|
autoGetDbKey: (options) => electron.ipcRenderer.invoke("key:autoGetDbKey", options),
|
||||||
|
autoGetImageKey: (options) => electron.ipcRenderer.invoke("key:autoGetImageKey", options),
|
||||||
|
getImageKeyConfig: () => electron.ipcRenderer.invoke("image:getConfig"),
|
||||||
|
getImageDecryptionStatus: () => electron.ipcRenderer.invoke("image:getStatus"),
|
||||||
|
saveImageKeyConfig: (request) => electron.ipcRenderer.invoke("image:saveConfig", request),
|
||||||
|
testImageDecryption: (request) => electron.ipcRenderer.invoke("image:testConfig", request),
|
||||||
|
clearImageKeyConfig: () => electron.ipcRenderer.invoke("image:clearConfig"),
|
||||||
|
pasteAndSaveDbKey: () => electron.ipcRenderer.invoke("key:pasteAndSaveDbKey"),
|
||||||
|
saveDbKey: (key) => electron.ipcRenderer.invoke("key:saveDbKey", key),
|
||||||
|
clearSavedDbKey: () => electron.ipcRenderer.invoke("key:clearSavedDbKey"),
|
||||||
|
onWcdbChange: (callback) => {
|
||||||
|
const listener = (_event, payload) => callback(payload);
|
||||||
|
electron.ipcRenderer.on("wcdb-change", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("wcdb-change", listener);
|
||||||
|
},
|
||||||
|
onDbKeyStatus: (callback) => {
|
||||||
|
const listener = (_event, payload) => callback(payload);
|
||||||
|
electron.ipcRenderer.on("key:dbKeyStatus", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("key:dbKeyStatus", listener);
|
||||||
|
},
|
||||||
|
onImageKeyStatus: (callback) => {
|
||||||
|
const listener = (_event, payload) => callback(payload);
|
||||||
|
electron.ipcRenderer.on("key:imageKeyStatus", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("key:imageKeyStatus", listener);
|
||||||
|
},
|
||||||
|
getSettings: () => electron.ipcRenderer.invoke("settings:get"),
|
||||||
|
setSettings: (patch) => electron.ipcRenderer.invoke("settings:set", patch),
|
||||||
|
getSelf: () => electron.ipcRenderer.invoke("settings:getSelf"),
|
||||||
|
testConnection: (key, accountRoot) => electron.ipcRenderer.invoke("db:testConnection", key, accountRoot),
|
||||||
|
reopenWithRoot: (accountRoot) => electron.ipcRenderer.invoke("db:reopenWithRoot", accountRoot),
|
||||||
|
selectDbRoot: () => electron.ipcRenderer.invoke("settings:selectDbRoot"),
|
||||||
|
openAccountRoot: () => electron.ipcRenderer.invoke("settings:openAccountRoot"),
|
||||||
|
disconnectDb: (options) => electron.ipcRenderer.invoke("db:disconnect", options),
|
||||||
|
apiStatus: () => electron.ipcRenderer.invoke("api:getStatus"),
|
||||||
|
apiStart: (host, port) => electron.ipcRenderer.invoke("api:start", host, port),
|
||||||
|
apiStop: () => electron.ipcRenderer.invoke("api:stop"),
|
||||||
|
apiToggle: (enabled) => electron.ipcRenderer.invoke("api:toggle", enabled),
|
||||||
|
getReaderSkillStatus: () => electron.ipcRenderer.invoke("api:skillStatus"),
|
||||||
|
readReaderSkill: () => electron.ipcRenderer.invoke("api:readSkill"),
|
||||||
|
revealReaderSkill: () => electron.ipcRenderer.invoke("api:revealSkill"),
|
||||||
|
openReaderSkillGithub: () => electron.ipcRenderer.invoke("api:openSkillGithub"),
|
||||||
|
testLocalApiRequest: (request) => electron.ipcRenderer.invoke("api:testLocalRequest", request),
|
||||||
|
copyText: (text) => electron.ipcRenderer.invoke("api:copyText", text),
|
||||||
|
// ============================================================
|
||||||
|
// AI 图片理解基础设施(ImageInsightService)
|
||||||
|
// ============================================================
|
||||||
|
imageListCandidates: (query) => electron.ipcRenderer.invoke("image:listCandidates", query),
|
||||||
|
imageAnalyze: (request) => electron.ipcRenderer.invoke("image:analyze", request),
|
||||||
|
getImageInsight: (imageHash) => electron.ipcRenderer.invoke("image:getInsight", imageHash),
|
||||||
|
listImageInsights: (sessionId, limit) => electron.ipcRenderer.invoke("image:listInsights", sessionId, limit),
|
||||||
|
getAgentHubStatus: () => electron.ipcRenderer.invoke("agent-hub:getStatus"),
|
||||||
|
getAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:getLogs"),
|
||||||
|
clearAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:clearLogs"),
|
||||||
|
startAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:startLogin"),
|
||||||
|
cancelAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:cancelLogin"),
|
||||||
|
reconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:reconnect"),
|
||||||
|
disconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:disconnect"),
|
||||||
|
selectAgentHubTestImage: () => electron.ipcRenderer.invoke("agent-hub:selectTestImage"),
|
||||||
|
onAgentHubStatus: (callback) => {
|
||||||
|
const listener = (_event, status) => callback(status);
|
||||||
|
electron.ipcRenderer.on("agent-hub:status", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("agent-hub:status", listener);
|
||||||
|
},
|
||||||
|
onAgentHubLog: (callback) => {
|
||||||
|
const listener = (_event, entry) => callback(entry);
|
||||||
|
electron.ipcRenderer.on("agent-hub:log", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("agent-hub:log", listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (process.contextIsolated) {
|
||||||
|
try {
|
||||||
|
electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI);
|
||||||
|
electron.contextBridge.exposeInMainWorld("api", api);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.electron = preload.electronAPI;
|
||||||
|
window.api = api;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wechatexplorer",
|
"name": "wechatexplorer",
|
||||||
"version": "2.1.1",
|
"version": "2.1.5",
|
||||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"wechat",
|
"wechat",
|
||||||
@@ -25,23 +25,33 @@
|
|||||||
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||||
"cp:env": "node scripts/ensure-env.cjs",
|
"cp:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:env": "node scripts/ensure-env.cjs",
|
"prepare:env": "node scripts/ensure-env.cjs",
|
||||||
"predev": "node scripts/ensure-env.cjs",
|
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||||
|
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
||||||
|
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
|
||||||
|
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
|
||||||
|
"build:native-services": "npm run build:wechat-connector",
|
||||||
|
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||||
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||||
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
|
"build:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
|
||||||
"build:mac:x64": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
||||||
"build:mac:arm64": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||||
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
"release": "npm run release:mac && npm run release:win",
|
||||||
|
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
||||||
|
"release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
|
||||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@koromix/koffi-win32-x64": "3.1.0",
|
||||||
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
|
"electron-updater": "^6.6.2",
|
||||||
"fs-extra": "^11.3.2",
|
"fs-extra": "^11.3.2",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
|
"jsonrepair": "^3.15.0",
|
||||||
"koffi": "^3.1.0",
|
"koffi": "^3.1.0",
|
||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
@@ -67,10 +77,21 @@
|
|||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
|
"sass": "^1.102.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6"
|
"vite": "^7.2.6"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
"supportedArchitectures": {
|
||||||
|
"os": [
|
||||||
|
"current",
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"current",
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"electron",
|
"electron",
|
||||||
"esbuild"
|
"esbuild"
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ specifiers:
|
|||||||
'@electron-toolkit/preload': ^3.0.2
|
'@electron-toolkit/preload': ^3.0.2
|
||||||
'@electron-toolkit/tsconfig': ^2.0.0
|
'@electron-toolkit/tsconfig': ^2.0.0
|
||||||
'@electron-toolkit/utils': ^4.0.0
|
'@electron-toolkit/utils': ^4.0.0
|
||||||
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
'@rollup/rollup-darwin-arm64': ^4.62.2
|
'@rollup/rollup-darwin-arm64': ^4.62.2
|
||||||
|
'@tanstack/react-virtual': ^3.14.6
|
||||||
'@types/fs-extra': ^11.0.4
|
'@types/fs-extra': ^11.0.4
|
||||||
'@types/node': ^22.19.1
|
'@types/node': ^22.19.1
|
||||||
'@types/react': ^19.2.7
|
'@types/react': ^19.2.7
|
||||||
@@ -18,6 +20,7 @@ specifiers:
|
|||||||
'@vitejs/plugin-react': ^5.1.1
|
'@vitejs/plugin-react': ^5.1.1
|
||||||
electron: ^43.0.0
|
electron: ^43.0.0
|
||||||
electron-builder: ^26.0.12
|
electron-builder: ^26.0.12
|
||||||
|
electron-updater: ^6.6.2
|
||||||
electron-vite: ^5.0.0
|
electron-vite: ^5.0.0
|
||||||
eslint: ^9.39.1
|
eslint: ^9.39.1
|
||||||
eslint-plugin-react: ^7.37.5
|
eslint-plugin-react: ^7.37.5
|
||||||
@@ -25,11 +28,13 @@ specifiers:
|
|||||||
eslint-plugin-react-refresh: ^0.4.24
|
eslint-plugin-react-refresh: ^0.4.24
|
||||||
fs-extra: ^11.3.2
|
fs-extra: ^11.3.2
|
||||||
fzstd: ^0.1.1
|
fzstd: ^0.1.1
|
||||||
|
jsonrepair: ^3.15.0
|
||||||
koffi: ^3.1.0
|
koffi: ^3.1.0
|
||||||
openai: ^6.10.0
|
openai: ^6.10.0
|
||||||
prettier: ^3.7.4
|
prettier: ^3.7.4
|
||||||
react: ^19.2.1
|
react: ^19.2.1
|
||||||
react-dom: ^19.2.1
|
react-dom: ^19.2.1
|
||||||
|
sass: ^1.102.0
|
||||||
silk-wasm: ^3.7.1
|
silk-wasm: ^3.7.1
|
||||||
typescript: ^5.9.3
|
typescript: ^5.9.3
|
||||||
vite: ^7.2.6
|
vite: ^7.2.6
|
||||||
@@ -38,8 +43,12 @@ specifiers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||||
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
||||||
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
|
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
||||||
|
electron-updater: 6.8.9
|
||||||
fs-extra: 11.3.2
|
fs-extra: 11.3.2
|
||||||
fzstd: 0.1.1
|
fzstd: 0.1.1
|
||||||
|
jsonrepair: 3.15.0
|
||||||
koffi: 3.1.0
|
koffi: 3.1.0
|
||||||
openai: 6.10.0
|
openai: 6.10.0
|
||||||
silk-wasm: 3.7.1
|
silk-wasm: 3.7.1
|
||||||
@@ -65,8 +74,9 @@ devDependencies:
|
|||||||
prettier: 3.7.4
|
prettier: 3.7.4
|
||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
react-dom: 19.2.1_react@19.2.1
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
sass: 1.102.0
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
vite: 7.2.7_@types+node@22.19.2
|
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -887,7 +897,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
|
||||||
|
|
||||||
/@malept/cross-spawn-promise/2.0.0:
|
/@malept/cross-spawn-promise/2.0.0:
|
||||||
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
||||||
@@ -925,6 +934,132 @@ packages:
|
|||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@parcel/watcher-android-arm64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-darwin-arm64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-darwin-x64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-freebsd-x64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm-glibc/2.6.0:
|
||||||
|
resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm-musl/2.6.0:
|
||||||
|
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm64-glibc/2.6.0:
|
||||||
|
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm64-musl/2.6.0:
|
||||||
|
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-x64-glibc/2.6.0:
|
||||||
|
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-x64-musl/2.6.0:
|
||||||
|
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-win32-arm64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-win32-x64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher/2.6.0:
|
||||||
|
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
is-glob: 4.0.3
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
picomatch: 4.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
'@parcel/watcher-android-arm64': 2.6.0
|
||||||
|
'@parcel/watcher-darwin-arm64': 2.6.0
|
||||||
|
'@parcel/watcher-darwin-x64': 2.6.0
|
||||||
|
'@parcel/watcher-freebsd-x64': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm-glibc': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm-musl': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm64-glibc': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm64-musl': 2.6.0
|
||||||
|
'@parcel/watcher-linux-x64-glibc': 2.6.0
|
||||||
|
'@parcel/watcher-linux-x64-musl': 2.6.0
|
||||||
|
'@parcel/watcher-win32-arm64': 2.6.0
|
||||||
|
'@parcel/watcher-win32-x64': 2.6.0
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/@pkgjs/parseargs/0.11.0:
|
/@pkgjs/parseargs/0.11.0:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1123,6 +1258,21 @@ packages:
|
|||||||
defer-to-connect: 2.0.1
|
defer-to-connect: 2.0.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq:
|
||||||
|
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.4
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@tanstack/virtual-core/3.17.4:
|
||||||
|
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@tootallnate/once/2.0.0:
|
/@tootallnate/once/2.0.0:
|
||||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -1409,7 +1559,7 @@ packages:
|
|||||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.18.0
|
react-refresh: 0.18.0
|
||||||
vite: 7.2.7_@types+node@22.19.2
|
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
@@ -1557,7 +1707,6 @@ packages:
|
|||||||
|
|
||||||
/argparse/2.0.1:
|
/argparse/2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/array-buffer-byte-length/1.0.2:
|
/array-buffer-byte-length/1.0.2:
|
||||||
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
||||||
@@ -1746,6 +1895,16 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/builder-util-runtime/9.7.0:
|
||||||
|
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
sax: 1.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: false
|
||||||
|
|
||||||
/builder-util/26.0.11:
|
/builder-util/26.0.11:
|
||||||
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
|
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1862,6 +2021,13 @@ packages:
|
|||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/chokidar/5.0.0:
|
||||||
|
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
dependencies:
|
||||||
|
readdirp: 5.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/chownr/2.0.0:
|
/chownr/2.0.0:
|
||||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2199,6 +2365,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/electron-updater/6.8.9:
|
||||||
|
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
|
||||||
|
dependencies:
|
||||||
|
builder-util-runtime: 9.7.0
|
||||||
|
fs-extra: 10.1.0
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
lazy-val: 1.0.5
|
||||||
|
lodash.escaperegexp: 4.1.2
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
semver: 7.7.3
|
||||||
|
tiny-typed-emitter: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: false
|
||||||
|
|
||||||
/electron-vite/5.0.0_vite@7.2.7:
|
/electron-vite/5.0.0_vite@7.2.7:
|
||||||
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
|
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2216,7 +2397,7 @@ packages:
|
|||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
vite: 7.2.7_@types+node@22.19.2
|
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
@@ -2719,7 +2900,6 @@ packages:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
jsonfile: 6.2.0
|
jsonfile: 6.2.0
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/fs-extra/11.3.2:
|
/fs-extra/11.3.2:
|
||||||
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
||||||
@@ -3071,6 +3251,10 @@ packages:
|
|||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/immutable/5.1.9:
|
||||||
|
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/import-fresh/3.3.1:
|
/import-fresh/3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3380,7 +3564,6 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/jsesc/3.1.0:
|
/jsesc/3.1.0:
|
||||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||||
@@ -3413,6 +3596,11 @@ packages:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
/jsonrepair/3.15.0:
|
||||||
|
resolution: {integrity: sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==}
|
||||||
|
hasBin: true
|
||||||
|
dev: false
|
||||||
|
|
||||||
/jsx-ast-utils/3.3.5:
|
/jsx-ast-utils/3.3.5:
|
||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -3450,7 +3638,6 @@ packages:
|
|||||||
|
|
||||||
/lazy-val/1.0.5:
|
/lazy-val/1.0.5:
|
||||||
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
|
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/levn/0.4.1:
|
/levn/0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
@@ -3467,6 +3654,15 @@ packages:
|
|||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/lodash.escaperegexp/4.1.2:
|
||||||
|
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/lodash.isequal/4.5.0:
|
||||||
|
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||||
|
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||||
|
dev: false
|
||||||
|
|
||||||
/lodash.merge/4.6.2:
|
/lodash.merge/4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -3717,6 +3913,11 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/node-addon-api/7.1.1:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/node-api-version/0.2.1:
|
/node-api-version/0.2.1:
|
||||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3937,6 +4138,12 @@ packages:
|
|||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/picomatch/4.0.5:
|
||||||
|
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/plist/3.1.0:
|
/plist/3.1.0:
|
||||||
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
||||||
engines: {node: '>=10.4.0'}
|
engines: {node: '>=10.4.0'}
|
||||||
@@ -4036,7 +4243,6 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/react-is/16.13.1:
|
/react-is/16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
@@ -4050,7 +4256,6 @@ packages:
|
|||||||
/react/19.2.1:
|
/react/19.2.1:
|
||||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/read-binary-file-arch/1.0.6:
|
/read-binary-file-arch/1.0.6:
|
||||||
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
|
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
|
||||||
@@ -4070,6 +4275,11 @@ packages:
|
|||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/readdirp/5.0.0:
|
||||||
|
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/reflect.getprototypeof/1.0.10:
|
/reflect.getprototypeof/1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4227,13 +4437,23 @@ packages:
|
|||||||
truncate-utf8-bytes: 1.0.2
|
truncate-utf8-bytes: 1.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/sass/1.102.0:
|
||||||
|
resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
chokidar: 5.0.0
|
||||||
|
immutable: 5.1.9
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@parcel/watcher': 2.6.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/sax/1.4.3:
|
/sax/1.4.3:
|
||||||
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
|
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/scheduler/0.27.0:
|
/scheduler/0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/semver/5.7.2:
|
/semver/5.7.2:
|
||||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||||
@@ -4578,6 +4798,10 @@ packages:
|
|||||||
semver: 5.7.2
|
semver: 5.7.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/tiny-typed-emitter/2.1.0:
|
||||||
|
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/tinyglobby/0.2.15:
|
/tinyglobby/0.2.15:
|
||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -4762,7 +4986,7 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/vite/7.2.7_@types+node@22.19.2:
|
/vite/7.2.7_kx7yyowhop237vgn7dp2p54shq:
|
||||||
resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==}
|
resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -4808,6 +5032,7 @@ packages:
|
|||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
rollup: 4.53.3
|
rollup: 4.53.3
|
||||||
|
sass: 1.102.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|||||||
|
After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -27,13 +27,13 @@
|
|||||||
.report {
|
.report {
|
||||||
width: 430px;
|
width: 430px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 22px 14px 34px;
|
padding: 20px 14px 34px;
|
||||||
}
|
}
|
||||||
.hero,
|
.hero,
|
||||||
.card,
|
.section,
|
||||||
.section {
|
.card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
@@ -41,32 +41,35 @@
|
|||||||
}
|
}
|
||||||
.hero-top {
|
.hero-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.hero-top > div:first-child {
|
.hero-top > div:first-child {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
font-size: 23px;
|
font-size: 23px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin: 0 0 8px;
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.sub {
|
.sub {
|
||||||
|
margin-top: 8px;
|
||||||
color: #667085;
|
color: #667085;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
.record-note {
|
.mode-tag {
|
||||||
color: #485465;
|
display: inline-flex;
|
||||||
font-weight: 650;
|
margin-top: 10px;
|
||||||
}
|
padding: 4px 10px;
|
||||||
.overview {
|
border-radius: 999px;
|
||||||
margin-top: 2px;
|
background: #eef8f2;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.avatar-grid {
|
.avatar-grid {
|
||||||
width: 58px;
|
width: 58px;
|
||||||
@@ -83,6 +86,49 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.hero-headline {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||||
|
}
|
||||||
|
.hero-headline b {
|
||||||
|
display: block;
|
||||||
|
font-size: 17px;
|
||||||
|
color: #076c39;
|
||||||
|
}
|
||||||
|
.hero-headline p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.hero-inline-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.hero-note,
|
||||||
|
.hero-status {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.hero-status {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: #f7faf9;
|
||||||
|
color: #076c39;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.hero-note.takeaway {
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #076c39;
|
||||||
|
}
|
||||||
|
.hero-note.pending {
|
||||||
|
background: #fff8e8;
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
.stats {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@@ -94,14 +140,11 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.stat b {
|
.stat b {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #07a352;
|
color: #07a352;
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.stat span {
|
.stat span {
|
||||||
@@ -109,16 +152,16 @@
|
|||||||
color: #667085;
|
color: #667085;
|
||||||
}
|
}
|
||||||
.section {
|
.section {
|
||||||
margin-top: 14px;
|
margin-top: 18px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
.section-title {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 18px;
|
margin-bottom: 14px;
|
||||||
|
font-size: 19px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
.section-title::before {
|
.section-title::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -127,11 +170,23 @@
|
|||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background: #07c160;
|
background: #07c160;
|
||||||
}
|
}
|
||||||
|
.section-subtitle {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.section-more {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.card {
|
.card {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid #edf0f2;
|
border: 1px solid #edf0f2;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.topic-title-row {
|
.topic-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -140,17 +195,17 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.topic-title-row h3 {
|
.topic-title-row h3 {
|
||||||
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
margin: 0;
|
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
.heat,
|
.heat,
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
background: #eef8f2;
|
background: #eef8f2;
|
||||||
color: #07a352;
|
color: #07a352;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -165,19 +220,44 @@
|
|||||||
background: #eef5ff;
|
background: #eef5ff;
|
||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
.red {
|
|
||||||
background: #fff1f0;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
.topic-meta {
|
.topic-meta {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.card p {
|
.card p {
|
||||||
|
margin: 10px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
margin: 10px 0 0;
|
}
|
||||||
|
.topic-conclusions {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.topic-conclusion {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #edf9f1;
|
||||||
|
color: #076c39;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.topic-inline-image {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7faf9;
|
||||||
|
}
|
||||||
|
.topic-inline-image img {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.participants {
|
.participants {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -189,7 +269,6 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-width: 0;
|
|
||||||
background: #f6f8fa;
|
background: #f6f8fa;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 3px 8px 3px 3px;
|
padding: 3px 8px 3px 3px;
|
||||||
@@ -198,7 +277,6 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
.person-chip b {
|
.person-chip b {
|
||||||
max-width: 58px;
|
max-width: 58px;
|
||||||
@@ -220,17 +298,6 @@
|
|||||||
background: #f2f4f7;
|
background: #f2f4f7;
|
||||||
color: #667085;
|
color: #667085;
|
||||||
}
|
}
|
||||||
.resource {
|
|
||||||
padding: 11px 12px;
|
|
||||||
background: #f7f8fa;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.resource b {
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
|
||||||
.important-card {
|
.important-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -255,9 +322,9 @@
|
|||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
}
|
}
|
||||||
.important-text {
|
.important-text {
|
||||||
|
margin-top: 5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin-top: 5px;
|
|
||||||
}
|
}
|
||||||
.important-note {
|
.important-note {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -269,6 +336,34 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
.action-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.action-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.todo-card {
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
.unresolved-card {
|
||||||
|
background: #fff8e8;
|
||||||
|
}
|
||||||
|
.action-card b {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.action-card div {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
.action-note {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
.chat-block {
|
.chat-block {
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -299,49 +394,180 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.quote-note {
|
.quote-note {
|
||||||
background: #fff8e1;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff8e1;
|
||||||
color: #8a5a00;
|
color: #8a5a00;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.qa-card {
|
.qa-card,
|
||||||
background: #f8fafc;
|
.resource {
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
.qa-card b {
|
.qa-card b,
|
||||||
|
.resource b {
|
||||||
display: block;
|
display: block;
|
||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
.qa-card div {
|
.qa-card div,
|
||||||
|
.resource {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: #485465;
|
color: #485465;
|
||||||
}
|
}
|
||||||
.bar-row {
|
.storyline-card,
|
||||||
|
.chain-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.storyline-steps {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 82px 1fr;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
margin-top: 10px;
|
||||||
margin-top: 9px;
|
}
|
||||||
|
.storyline-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.storyline-step span {
|
||||||
|
color: #8a94a6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.bar {
|
.storyline-step b {
|
||||||
height: 10px;
|
font-size: 13px;
|
||||||
background: #edf1f5;
|
line-height: 1.5;
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.bar i {
|
.chain-flow {
|
||||||
display: block;
|
display: flex;
|
||||||
height: 100%;
|
flex-wrap: wrap;
|
||||||
background: #07c160;
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chain-flow span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 9px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #076c39;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.chain-flow i {
|
||||||
|
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));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
/* AI 图片识别板块 */
|
||||||
|
.vision-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 132px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||||
|
border: 1px solid #d6efde;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.vision-image {
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.vision-description {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.vision-ocr {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.vision-tags {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.vision-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.vision-label {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #07a352;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-card {
|
||||||
|
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
|
||||||
|
border: 1px solid #edf0f2;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.badge-card b {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.badge-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.data-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.rank {
|
.rank {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -350,6 +576,9 @@
|
|||||||
padding: 9px 0;
|
padding: 9px 0;
|
||||||
border-bottom: 1px solid #eef0f2;
|
border-bottom: 1px solid #eef0f2;
|
||||||
}
|
}
|
||||||
|
.rank:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.rank img {
|
.rank img {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -402,7 +631,58 @@
|
|||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
}
|
}
|
||||||
.empty-section {
|
.empty-section {
|
||||||
display: none;
|
display: none !important;
|
||||||
|
}
|
||||||
|
.compact .report {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
.compact .section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.compact .card {
|
||||||
|
padding: 11px;
|
||||||
|
}
|
||||||
|
.compact .important-card,
|
||||||
|
.compact .gallery-card,
|
||||||
|
.compact .chat-block {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.compact .section-title {
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.compact .hero-headline p,
|
||||||
|
.compact .card p,
|
||||||
|
.compact .important-text,
|
||||||
|
.compact .chat-bubble {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.compact .participants,
|
||||||
|
.compact .keywords,
|
||||||
|
.compact .hero-inline-notes {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.compact .topic-conclusions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.compact .topic-inline-image {
|
||||||
|
grid-template-columns: 64px 1fr;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.compact .topic-inline-image img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
.compact .stats {
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.compact .stat {
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
.compact .stat b {
|
||||||
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
@media (max-width: 430px) {
|
@media (max-width: 430px) {
|
||||||
html,
|
html,
|
||||||
@@ -414,83 +694,144 @@
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
.stats {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.stat b {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{{REPORT_MODE_CLASS}}">
|
||||||
<main class="report">
|
<main class="report">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-top">
|
<div class="hero-top">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{GROUP_NAME}}日报</h1>
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
<div class="sub">
|
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||||
<div>{{DATE_RANGE}}</div>
|
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
|
||||||
<div class="record-note">{{RECORD_NOTE}}</div>
|
|
||||||
<div class="overview">{{OVERVIEW}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-headline">
|
||||||
|
<b>{{HERO_HEADLINE}}</b>
|
||||||
|
<p>{{HERO_SUMMARY}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
|
||||||
|
<div class="hero-inline-notes">
|
||||||
|
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
|
||||||
|
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
|
||||||
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
|
||||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="section topics">
|
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">今日讨论热点</div>
|
<div class="section-title">今日讨论热点</div>
|
||||||
{{TOPIC_CARDS}}
|
{{TOPIC_CARDS}}
|
||||||
|
{{TOPICS_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">重要消息</div>
|
||||||
|
{{IMPORTANT_MESSAGES}}
|
||||||
|
{{MESSAGES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{ACTIONS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">待办事项和未解决问题</div>
|
||||||
|
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
|
||||||
|
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
|
||||||
|
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
|
||||||
|
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
|
||||||
|
{{ACTIONS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{QUOTES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日名场面</div>
|
||||||
|
{{QUOTE_BLOCKS}}
|
||||||
|
{{QUOTES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日群数据</div>
|
||||||
|
<div class="data-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
|
||||||
|
{{RANK_ITEMS}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||||
|
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">关键词</div>
|
||||||
|
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||||
|
{{KEYWORDS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{RESOURCES_EMPTY_CLASS}}">
|
||||||
<div class="section-title">实用信息与资源</div>
|
<div class="section-title">实用信息与资源</div>
|
||||||
{{RESOURCE_ITEMS}}
|
{{RESOURCE_ITEMS}}
|
||||||
|
{{RESOURCES_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
<section class="section {{QA_EMPTY_CLASS}}">
|
||||||
<div class="section-title">重要消息汇总</div>
|
|
||||||
{{IMPORTANT_MESSAGES}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
|
||||||
<div class="section-title">有趣对话或金句</div>
|
|
||||||
{{QUOTE_BLOCKS}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section qa {{QA_EMPTY_CLASS}}">
|
|
||||||
<div class="section-title">问题与解答</div>
|
<div class="section-title">问题与解答</div>
|
||||||
{{QA_CARDS}}
|
{{QA_CARDS}}
|
||||||
|
{{QA_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section analytics">
|
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||||
<div class="section-title">群内数据可视化</div>
|
<div class="section-title">今日剧情时间线</div>
|
||||||
{{HEAT_BARS}}
|
{{STORYLINE_CARDS}}
|
||||||
<div class="card">
|
{{STORYLINES_MORE_NOTE}}
|
||||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
|
||||||
话唠榜 TOP5(基于已读取记录估算)
|
|
||||||
</div>
|
|
||||||
{{RANK_ITEMS}}
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section cloud">
|
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">词云/关键词</div>
|
<div class="section-title">群聊反转现场</div>
|
||||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
{{REVERSAL_CARDS}}
|
||||||
|
{{REVERSALS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VISION_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">{{VISION_TITLE}}</div>
|
||||||
|
{{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>
|
||||||
|
{{VOICE_CARDS}}
|
||||||
|
{{VOICE_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">语音时长榜</div>
|
||||||
|
<div class="card">{{VOICE_RANK_CARDS}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{BADGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日临时人设</div>
|
||||||
|
<div class="badge-grid">{{BADGE_CARDS}}</div>
|
||||||
|
{{BADGES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{CHAINS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">话题参与链路</div>
|
||||||
|
{{CHAIN_CARDS}}
|
||||||
|
{{CHAINS_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
数据来源:微信群聊记录<br />
|
||||||
生成时间:{{GENERATED_AT}}<br />
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
{{FOOTER_NOTE}}
|
{{FOOTER_NOTE}}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,592 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>{{REPORT_TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
width: 430px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 430px;
|
||||||
|
background: #f3f5f7;
|
||||||
|
color: #1f2933;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 430px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 22px 14px 34px;
|
||||||
|
}
|
||||||
|
.hero,
|
||||||
|
.card,
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.hero-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hero-top > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.record-note {
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.overview {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.avatar-grid {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.avatar-grid img,
|
||||||
|
.avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #07a352;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #07c160;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #edf0f2;
|
||||||
|
}
|
||||||
|
.topic-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topic-title-row h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
.heat,
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hot {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #d46b08;
|
||||||
|
}
|
||||||
|
.blue {
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.red {
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
.topic-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.participants {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.person-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px 3px 3px;
|
||||||
|
}
|
||||||
|
.person-chip img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.person-chip b {
|
||||||
|
max-width: 58px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.keywords {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.keywords span {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.resource {
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.resource b {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.important-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.important-card > .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.important-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.important-meta b {
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.important-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.important-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-left: 3px solid #07c160;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.chat-block {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.chat-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.chat-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.chat-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px 12px 12px 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.quote-note {
|
||||||
|
background: #fff8e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.qa-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.qa-card b {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.qa-card div {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
.bar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 10px;
|
||||||
|
background: #edf1f5;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: #07c160;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.rank {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid #eef0f2;
|
||||||
|
}
|
||||||
|
.rank img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.rank b {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rank span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cloud-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cloud-tags span {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.cloud-tags .xl {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #07a352;
|
||||||
|
background: #e9f8ef;
|
||||||
|
}
|
||||||
|
.cloud-tags .lg {
|
||||||
|
font-size: 17px;
|
||||||
|
color: #1677ff;
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
.cloud-tags .md {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #d46b08;
|
||||||
|
background: #fff4e5;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 16px 4px 0;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #8a94a6;
|
||||||
|
}
|
||||||
|
.empty-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* AI 图片识别板块(v1 模板) */
|
||||||
|
.vision-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 132px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||||
|
border: 1px solid #d6efde;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.vision-image {
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.vision-description {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.vision-ocr {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.vision-tags {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.vision-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.vision-label {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #07a352;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
/* 热度条形图(v1 模板) */
|
||||||
|
.heat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr 40px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.heat-name {
|
||||||
|
color: #1f2933;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.heat-bar {
|
||||||
|
background: #f3f5f7;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.heat-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #07c160 0%, #34d399 100%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.heat-val {
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="report">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
|
<div class="sub">
|
||||||
|
<div>{{DATE_RANGE}}</div>
|
||||||
|
<div class="record-note">{{RECORD_NOTE}}</div>
|
||||||
|
<div class="overview">{{OVERVIEW}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
|
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
|
||||||
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section topics">
|
||||||
|
<div class="section-title">今日讨论热点</div>
|
||||||
|
{{TOPIC_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section vision {{VISION_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">{{VISION_TITLE}}</div>
|
||||||
|
{{VISION_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">实用信息与资源</div>
|
||||||
|
{{RESOURCE_ITEMS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">重要消息汇总</div>
|
||||||
|
{{IMPORTANT_MESSAGES}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">有趣对话或金句</div>
|
||||||
|
{{QUOTE_BLOCKS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section qa {{QA_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">问题与解答</div>
|
||||||
|
{{QA_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section analytics">
|
||||||
|
<div class="section-title">群内数据可视化</div>
|
||||||
|
{{HEAT_BARS}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
||||||
|
话唠榜 TOP5(基于已读取记录估算)
|
||||||
|
</div>
|
||||||
|
{{RANK_ITEMS}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section cloud">
|
||||||
|
<div class="section-title">词云/关键词</div>
|
||||||
|
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||||
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
|
{{FOOTER_NOTE}}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>{{REPORT_TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
width: 430px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 430px;
|
||||||
|
background: #f3f5f7;
|
||||||
|
color: #1f2933;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 430px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 14px 34px;
|
||||||
|
}
|
||||||
|
.hero,
|
||||||
|
.section,
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.hero-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.hero-top > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.avatar-grid {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.avatar-grid img,
|
||||||
|
.avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.hero-headline {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||||
|
}
|
||||||
|
.hero-headline b {
|
||||||
|
display: block;
|
||||||
|
font-size: 17px;
|
||||||
|
color: #076c39;
|
||||||
|
}
|
||||||
|
.hero-headline p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.hero-inline-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.hero-note,
|
||||||
|
.hero-status {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.hero-status {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: #f7faf9;
|
||||||
|
color: #076c39;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.hero-note.takeaway {
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #076c39;
|
||||||
|
}
|
||||||
|
.hero-note.pending {
|
||||||
|
background: #fff8e8;
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #07a352;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #07c160;
|
||||||
|
}
|
||||||
|
.section-subtitle {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.section-more {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px solid #edf0f2;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.topic-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topic-title-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
.heat,
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hot {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #d46b08;
|
||||||
|
}
|
||||||
|
.blue {
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.topic-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.topic-conclusions {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.topic-conclusion {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #edf9f1;
|
||||||
|
color: #076c39;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.topic-inline-image {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7faf9;
|
||||||
|
}
|
||||||
|
.topic-inline-image img {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.participants {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.person-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px 3px 3px;
|
||||||
|
}
|
||||||
|
.person-chip img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.person-chip b {
|
||||||
|
max-width: 58px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.keywords {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.keywords span {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.important-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.important-card > .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.important-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.important-meta b {
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.important-text {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.important-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-left: 3px solid #07c160;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.action-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.action-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.todo-card {
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
.unresolved-card {
|
||||||
|
background: #fff8e8;
|
||||||
|
}
|
||||||
|
.action-card b {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.action-card div {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
.action-note {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.chat-block {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.chat-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.chat-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.chat-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px 12px 12px 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.quote-note {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.qa-card,
|
||||||
|
.resource {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.qa-card b,
|
||||||
|
.resource b {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.qa-card div,
|
||||||
|
.resource {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
.storyline-card,
|
||||||
|
.chain-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.storyline-steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.storyline-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.storyline-step span {
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.storyline-step b {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.chain-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chain-flow span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #076c39;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.chain-flow i {
|
||||||
|
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));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
/* AI 图片识别板块 */
|
||||||
|
.vision-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 132px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||||
|
border: 1px solid #d6efde;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.vision-image {
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.vision-description {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.vision-ocr {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.vision-tags {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.vision-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.vision-label {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #07a352;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-card {
|
||||||
|
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
|
||||||
|
border: 1px solid #edf0f2;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.badge-card b {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.badge-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.data-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.rank {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid #eef0f2;
|
||||||
|
}
|
||||||
|
.rank:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.rank img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.rank b {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rank span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cloud-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cloud-tags span {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.cloud-tags .xl {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #07a352;
|
||||||
|
background: #e9f8ef;
|
||||||
|
}
|
||||||
|
.cloud-tags .lg {
|
||||||
|
font-size: 17px;
|
||||||
|
color: #1677ff;
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
.cloud-tags .md {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #d46b08;
|
||||||
|
background: #fff4e5;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 16px 4px 0;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #8a94a6;
|
||||||
|
}
|
||||||
|
.empty-section {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.compact .report {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
.compact .section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.compact .card {
|
||||||
|
padding: 11px;
|
||||||
|
}
|
||||||
|
.compact .important-card,
|
||||||
|
.compact .gallery-card,
|
||||||
|
.compact .chat-block {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.compact .section-title {
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.compact .hero-headline p,
|
||||||
|
.compact .card p,
|
||||||
|
.compact .important-text,
|
||||||
|
.compact .chat-bubble {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.compact .participants,
|
||||||
|
.compact .keywords,
|
||||||
|
.compact .hero-inline-notes {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.compact .topic-conclusions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.compact .topic-inline-image {
|
||||||
|
grid-template-columns: 64px 1fr;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.compact .topic-inline-image img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
.compact .stats {
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.compact .stat {
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
.compact .stat b {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{{REPORT_MODE_CLASS}}">
|
||||||
|
<main class="report">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
|
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-headline">
|
||||||
|
<b>{{HERO_HEADLINE}}</b>
|
||||||
|
<p>{{HERO_SUMMARY}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
|
||||||
|
<div class="hero-inline-notes">
|
||||||
|
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
|
||||||
|
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
|
||||||
|
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日讨论热点</div>
|
||||||
|
{{TOPIC_CARDS}}
|
||||||
|
{{TOPICS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">重要消息</div>
|
||||||
|
{{IMPORTANT_MESSAGES}}
|
||||||
|
{{MESSAGES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{ACTIONS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">待办事项和未解决问题</div>
|
||||||
|
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
|
||||||
|
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
|
||||||
|
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
|
||||||
|
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
|
||||||
|
{{ACTIONS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{QUOTES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日名场面</div>
|
||||||
|
{{QUOTE_BLOCKS}}
|
||||||
|
{{QUOTES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日群数据</div>
|
||||||
|
<div class="data-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
|
||||||
|
{{RANK_ITEMS}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||||
|
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">关键词</div>
|
||||||
|
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||||
|
{{KEYWORDS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{RESOURCES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">实用信息与资源</div>
|
||||||
|
{{RESOURCE_ITEMS}}
|
||||||
|
{{RESOURCES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{QA_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">问题与解答</div>
|
||||||
|
{{QA_CARDS}}
|
||||||
|
{{QA_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日剧情时间线</div>
|
||||||
|
{{STORYLINE_CARDS}}
|
||||||
|
{{STORYLINES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">群聊反转现场</div>
|
||||||
|
{{REVERSAL_CARDS}}
|
||||||
|
{{REVERSALS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VISION_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">{{VISION_TITLE}}</div>
|
||||||
|
{{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>
|
||||||
|
{{VOICE_CARDS}}
|
||||||
|
{{VOICE_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">语音时长榜</div>
|
||||||
|
<div class="card">{{VOICE_RANK_CARDS}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{BADGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日临时人设</div>
|
||||||
|
<div class="badge-grid">{{BADGE_CARDS}}</div>
|
||||||
|
{{BADGES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{CHAINS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">话题参与链路</div>
|
||||||
|
{{CHAIN_CARDS}}
|
||||||
|
{{CHAINS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||||
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
|
{{FOOTER_NOTE}}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,23 @@ function setPlistValue(plistPath, key, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exports.default = async function afterPack(context) {
|
exports.default = async function afterPack(context) {
|
||||||
|
if (context.electronPlatformName === 'win32') {
|
||||||
|
const koffiNative = path.join(
|
||||||
|
context.appOutDir,
|
||||||
|
'resources',
|
||||||
|
'app.asar.unpacked',
|
||||||
|
'node_modules',
|
||||||
|
'@koromix',
|
||||||
|
'koffi-win32-x64',
|
||||||
|
'win32_x64',
|
||||||
|
'koffi.node'
|
||||||
|
)
|
||||||
|
if (!existsSync(koffiNative)) {
|
||||||
|
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (context.electronPlatformName !== 'darwin') return
|
if (context.electronPlatformName !== 'darwin') return
|
||||||
|
|
||||||
const productName = context.packager.appInfo.productFilename
|
const productName = context.packager.appInfo.productFilename
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
|
||||||
|
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
|
||||||
|
|
||||||
|
function normalizePlatform(value) {
|
||||||
|
if (value === 'win32' || value === 'windows') return 'windows'
|
||||||
|
if (value === 'darwin' || value === 'macos') return 'darwin'
|
||||||
|
if (value === 'linux') return 'linux'
|
||||||
|
throw new Error(`Unsupported connector platform: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArch(value) {
|
||||||
|
if (value === 'x64' || value === 'amd64') return 'amd64'
|
||||||
|
if (value === 'arm64') return 'arm64'
|
||||||
|
throw new Error(`Unsupported connector architecture: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHostArch() {
|
||||||
|
if (process.platform !== 'darwin') return process.arch
|
||||||
|
try {
|
||||||
|
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
|
||||||
|
encoding: 'utf8'
|
||||||
|
}).trim()
|
||||||
|
return arm64Supported === '1' ? 'arm64' : process.arch
|
||||||
|
} catch {
|
||||||
|
return process.arch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTargets() {
|
||||||
|
const platformArg = process.argv.indexOf('--platform')
|
||||||
|
const archArg = process.argv.indexOf('--arch')
|
||||||
|
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
|
||||||
|
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
|
||||||
|
return platforms.flatMap((platform) =>
|
||||||
|
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
|
||||||
|
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of parseTargets()) {
|
||||||
|
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
|
||||||
|
const outputDir = path.join(outputRoot, directoryName)
|
||||||
|
const outputPath = path.join(
|
||||||
|
outputDir,
|
||||||
|
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
|
||||||
|
)
|
||||||
|
fs.rmSync(outputDir, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
|
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
|
||||||
|
cwd: sourceDir,
|
||||||
|
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
|
||||||
|
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
|
||||||
|
const outputDir = path.join(os.tmpdir(), 'wechatexplorer-report-fixtures')
|
||||||
|
|
||||||
|
const escapeHtml = (value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
|
||||||
|
|
||||||
|
const avatarSvg = (label, color) =>
|
||||||
|
`data:image/svg+xml;base64,${Buffer.from(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
|
||||||
|
).toString('base64')}`
|
||||||
|
|
||||||
|
const localImagePath = '/Users/Wxw_/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/a969409112_d784/temp/RWTemp/2026-07/94ce24699a5a1d539c00a37ec8ace755.png'
|
||||||
|
const sampleImage = fs.existsSync(localImagePath)
|
||||||
|
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
|
||||||
|
: avatarSvg('图', '#dbeafe')
|
||||||
|
|
||||||
|
const avatars = {
|
||||||
|
阿宇: avatarSvg('宇', '#dcfce7'),
|
||||||
|
老周: avatarSvg('周', '#e0f2fe'),
|
||||||
|
小李: avatarSvg('李', '#fef3c7'),
|
||||||
|
'we water': avatarSvg('W', '#ede9fe'),
|
||||||
|
佩佩: avatarSvg('佩', '#fee2e2')
|
||||||
|
}
|
||||||
|
|
||||||
|
const heroNames = ['阿宇', '老周', '小李', 'we water']
|
||||||
|
const heroAvatars = heroNames
|
||||||
|
.map((name) => `<img src="${avatars[name]}" alt="${name}">`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const compactRequest = {
|
||||||
|
metadata: {
|
||||||
|
groupName: '技术交流',
|
||||||
|
reportDate: '2026-07-10',
|
||||||
|
dateRange: '2026-07-10 09:12-19:48',
|
||||||
|
messageCount: 382,
|
||||||
|
activeUsers: 47,
|
||||||
|
imageCount: 9,
|
||||||
|
voiceCount: 5,
|
||||||
|
stickerCount: 14,
|
||||||
|
mediaMessageCount: 28,
|
||||||
|
timeSpan: '11 h',
|
||||||
|
generatedAt: '2026-07-10 22:18',
|
||||||
|
recordNote: '基于示例数据生成的精简版日报',
|
||||||
|
footerNote: '精简版默认面向长图转发;无内容模块自动隐藏。',
|
||||||
|
heroParticipants: heroNames,
|
||||||
|
avatars,
|
||||||
|
reportMode: 'compact'
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
hero: {
|
||||||
|
headline: '接口排查和版本升级是今天主线',
|
||||||
|
summary: '白天主要围绕接口异常、升级节奏和上线安排展开,结论比争论更多,待跟进事项也比较集中。',
|
||||||
|
keyTakeaway: '大家确认本次异常更像缓存与配置问题,而不是服务端挂掉。',
|
||||||
|
pendingNote: '测试环境接口文档和回滚方案仍需补齐。',
|
||||||
|
statusLine: '今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决'
|
||||||
|
},
|
||||||
|
summaryStats: {
|
||||||
|
messageCount: 382,
|
||||||
|
activeUsers: 47,
|
||||||
|
topicCount: 4,
|
||||||
|
mediaCount: 28,
|
||||||
|
imageCount: 9,
|
||||||
|
voiceCount: 5,
|
||||||
|
stickerCount: 14,
|
||||||
|
conclusionCount: 3,
|
||||||
|
todoCount: 2,
|
||||||
|
unresolvedCount: 1
|
||||||
|
},
|
||||||
|
sectionMeta: {
|
||||||
|
hero: { enabled: true, importance: 1, confidence: 0.95, totalCount: 1, displayedCount: 1 },
|
||||||
|
topics: { enabled: true, importance: 0.98, confidence: 0.86, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
|
||||||
|
importantMessages: { enabled: true, importance: 0.95, confidence: 0.84, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
|
||||||
|
actions: { enabled: true, importance: 0.97, confidence: 0.81, totalCount: 5, displayedCount: 3, hiddenCount: 2 },
|
||||||
|
moments: { enabled: true, importance: 0.75, confidence: 0.75, totalCount: 3, displayedCount: 1, hiddenCount: 2 },
|
||||||
|
analytics: { enabled: true, importance: 0.8, confidence: 0.95, totalCount: 1, displayedCount: 1 },
|
||||||
|
keywords: { enabled: true, importance: 0.68, confidence: 0.92, totalCount: 16, displayedCount: 12, hiddenCount: 4 }
|
||||||
|
},
|
||||||
|
topics: [
|
||||||
|
{
|
||||||
|
title: 'GPT 接口异常排查',
|
||||||
|
timeRange: '09:20-11:05',
|
||||||
|
heat: '高',
|
||||||
|
summary: '上午先从接口超时和返回结构异常入手,几轮排查后,大家逐步把问题收敛到缓存与环境配置,而不是后端服务不可用。',
|
||||||
|
conclusions: [
|
||||||
|
{ text: '接口本身可用,异常更像本地缓存与环境变量冲突。' },
|
||||||
|
{ text: '先清缓存再复测,避免把旧响应误判成线上事故。' }
|
||||||
|
],
|
||||||
|
participants: ['阿宇', '老周', '小李'],
|
||||||
|
keywords: ['接口', '缓存', '环境变量'],
|
||||||
|
image: {
|
||||||
|
imageUrl: sampleImage,
|
||||||
|
note: '该图片引发 12 条回复。根据图片前后对话推断,这是一张帮助定位问题的截图。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '版本升级节奏',
|
||||||
|
timeRange: '11:40-12:20',
|
||||||
|
heat: '中',
|
||||||
|
summary: '关于 React 版本是否立刻升级,讨论从“能不能升”转成“这周值不值得升”,最终倾向先补兼容性清单再动。',
|
||||||
|
conclusions: [{ text: '先列旧组件兼容清单,再决定升级窗口。' }],
|
||||||
|
participants: ['阿宇', 'we water', '佩佩'],
|
||||||
|
keywords: ['React', '升级', '兼容性']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上线节奏与回滚准备',
|
||||||
|
timeRange: '15:10-16:05',
|
||||||
|
heat: '中',
|
||||||
|
summary: '下午讨论上线方案时,大家更关注回滚准备是否充分,最后把重点放在文档、监控和回滚路径补齐上。',
|
||||||
|
conclusions: [{ text: '上线前需要补一版简短回滚说明。' }],
|
||||||
|
participants: ['老周', '小李'],
|
||||||
|
keywords: ['上线', '回滚', '监控']
|
||||||
|
},
|
||||||
|
],
|
||||||
|
importantMessages: [
|
||||||
|
{ sender: '阿宇', time: '10:41', content: '先别回滚,接口能通。', note: '稳定了排查方向。' },
|
||||||
|
{ sender: '老周', time: '11:02', content: '像是缓存没清掉。', note: '把问题从服务端收敛到本地环境。' },
|
||||||
|
{ sender: '小李', time: '15:36', content: '上线前把回滚文档补一下。', note: '明确形成待办。' }
|
||||||
|
],
|
||||||
|
todos: [
|
||||||
|
{ task: '补测试环境接口文档', owner: '小李', topic: 'GPT 接口异常排查', note: '方便明天复测。' },
|
||||||
|
{ task: '整理回滚说明', owner: '老周', deadline: '今晚', topic: '上线节奏与回滚准备' }
|
||||||
|
],
|
||||||
|
unresolved: [
|
||||||
|
{ question: '缓存问题的根因是不是插件残留?', owner: '阿宇', status: '待跟进', lastDiscussedAt: '18:26', note: '目前只有推断,还没有最终证据。' }
|
||||||
|
],
|
||||||
|
quotes: [
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
{ sender: '阿宇', content: '我以为接口炸了。' },
|
||||||
|
{ sender: '老周', content: '先别慌,先清缓存。' },
|
||||||
|
{ sender: '小李', content: '清完它居然真好了。' }
|
||||||
|
],
|
||||||
|
note: '从“要不要回滚”迅速切到“先做最小验证”,很像今天的群聊节奏。'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
analytics: {
|
||||||
|
topSpeakers: [
|
||||||
|
{ name: '阿宇', count: 112 },
|
||||||
|
{ name: '老周', count: 78 },
|
||||||
|
{ name: '小李', count: 61 },
|
||||||
|
{ name: 'we water', count: 49 },
|
||||||
|
{ name: '佩佩', count: 33 }
|
||||||
|
],
|
||||||
|
activeTimeline: '09:00-09:59、10:00-10:59、15:00-15:59',
|
||||||
|
voiceLeaderboard: []
|
||||||
|
},
|
||||||
|
keywords: ['GPT', '接口', '缓存', '升级', '上线', '回滚', '监控', '兼容性', '截图', '文档', '测试环境', '复测'],
|
||||||
|
media: {
|
||||||
|
gallery: [],
|
||||||
|
voiceHighlights: [],
|
||||||
|
funBadges: []
|
||||||
|
},
|
||||||
|
resources: [],
|
||||||
|
qa: [],
|
||||||
|
storylines: [],
|
||||||
|
reversals: [],
|
||||||
|
participantChains: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullRequest = JSON.parse(JSON.stringify(compactRequest))
|
||||||
|
fullRequest.metadata.recordNote = '基于示例数据生成的完整版日报'
|
||||||
|
fullRequest.metadata.reportMode = 'full'
|
||||||
|
fullRequest.report.sectionMeta = {
|
||||||
|
...fullRequest.report.sectionMeta,
|
||||||
|
resources: { enabled: true, importance: 0.58, confidence: 0.76, totalCount: 2, displayedCount: 2 },
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
fullRequest.report.resources = [
|
||||||
|
{ title: '测试环境接口文档', description: '明天复测会直接用到的说明。', sender: '小李' },
|
||||||
|
{ title: '回滚说明草稿', description: '上线前确认回滚路径与负责人。', sender: '老周' }
|
||||||
|
]
|
||||||
|
fullRequest.report.qa = [
|
||||||
|
{ question: '今晚要不要升级 React?', answer: '先不升级,先补兼容清单。', answerer: 'we water' },
|
||||||
|
{ question: '接口是不是服务端挂了?', answer: '不是,当前更像缓存与环境问题。', answerer: '老周' }
|
||||||
|
]
|
||||||
|
fullRequest.report.storylines = [
|
||||||
|
{
|
||||||
|
title: '接口异常排查线',
|
||||||
|
stages: [
|
||||||
|
{ time: '09:20', event: '阿宇提出接口异常。' },
|
||||||
|
{ time: '09:46', event: '老周建议先清缓存。' },
|
||||||
|
{ time: '10:41', event: '确认接口本身可通。' }
|
||||||
|
],
|
||||||
|
result: '初步定位为缓存与配置冲突。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '版本升级讨论线',
|
||||||
|
stages: [
|
||||||
|
{ time: '11:40', event: '开始讨论是否本周升级。' },
|
||||||
|
{ time: '12:05', event: '补充兼容性与工期顾虑。' }
|
||||||
|
],
|
||||||
|
result: '今晚不升,先补兼容清单。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
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: '基于图片后的聊天上下文推断'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
voiceHighlights: [
|
||||||
|
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
|
||||||
|
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
|
||||||
|
],
|
||||||
|
funBadges: [
|
||||||
|
{ title: '高能输出王', owner: '阿宇', note: '今天一共发了 112 条消息。' },
|
||||||
|
{ title: '语音麦霸', owner: '老周', note: '语音总时长位列第一。' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
fullRequest.report.participantChains = [
|
||||||
|
{ topic: '接口异常排查', chain: ['阿宇 提出', '老周 收敛方向', '小李 验证', 'we water 定结论'], note: '比较典型的一条技术讨论链路。' }
|
||||||
|
]
|
||||||
|
fullRequest.report.analytics.voiceLeaderboard = [
|
||||||
|
{ sender: '老周', count: 3, durationSec: 97 },
|
||||||
|
{ sender: '阿宇', count: 2, durationSec: 54 }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function renderRequest(request, targetBase) {
|
||||||
|
let html = await fs.readFile(templatePath, 'utf8')
|
||||||
|
const report = request.report
|
||||||
|
const metadata = request.metadata
|
||||||
|
const topicCards = report.topics
|
||||||
|
.map((topic) => {
|
||||||
|
const conclusions = (topic.conclusions || [])
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||||
|
.join('')
|
||||||
|
const image = topic.image?.imageUrl
|
||||||
|
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||||
|
: ''
|
||||||
|
return `<div class="card topic-card"><div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${topic.heat === '高' ? 'hot' : topic.heat === '低' ? 'blue' : ''}">${escapeHtml(topic.heat)}热</span></div><div class="topic-meta">${escapeHtml(topic.timeRange)}</div><p>${escapeHtml(topic.summary)}</p>${conclusions ? `<div class="topic-conclusions">${conclusions}</div>` : ''}${image}<div class="participants">${topic.participants.map((name) => `<span class="person-chip"><img src="${avatars[name] || avatarSvg(name[0], '#e5e7eb')}" alt=""><b>${escapeHtml(name)}</b></span>`).join('')}</div><div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div></div>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
const importantMessages = report.importantMessages
|
||||||
|
.map((message) => `<div class="important-card"><img class="avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div><div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div></div>`)
|
||||||
|
.join('')
|
||||||
|
const todoCards = (report.todos || []).map((item) => `<div class="action-card todo-card"><b>${escapeHtml(item.task)}</b><div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||||
|
const unresolvedCards = (report.unresolved || []).map((item) => `<div class="action-card unresolved-card"><b>${escapeHtml(item.question)}</b><div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div><div class="action-note">${escapeHtml(item.note)}</div></div>`).join('')
|
||||||
|
const quoteBlocks = report.quotes.map((quote) => `<div class="chat-block">${quote.messages.map((message) => `<div class="chat-msg"><img class="chat-avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div><div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div></div></div>`).join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`).join('')
|
||||||
|
const rankItems = report.analytics.topSpeakers.map((speaker, index) => `<div class="rank"><img src="${avatars[speaker.name] || avatarSvg(speaker.name[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${speaker.count} 条</span></div>`).join('')
|
||||||
|
const cloudTags = report.keywords.map((word, index) => `<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`).join('')
|
||||||
|
const resourceItems = (report.resources || []).map((resource) => `<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`).join('')
|
||||||
|
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('')
|
||||||
|
const chainCards = (report.participantChains || []).map((item) => `<div class="card chain-card"><div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div><div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}</div>`).join('')
|
||||||
|
|
||||||
|
const replaceMap = {
|
||||||
|
REPORT_TITLE: `${metadata.groupName}日报`,
|
||||||
|
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||||
|
GROUP_NAME: metadata.groupName,
|
||||||
|
DATE_RANGE: metadata.dateRange,
|
||||||
|
RECORD_NOTE: metadata.recordNote,
|
||||||
|
REPORT_MODE_LABEL: metadata.reportMode === 'full' ? '完整版' : '精简版',
|
||||||
|
HERO_HEADLINE: report.hero.headline,
|
||||||
|
HERO_SUMMARY: report.hero.summary,
|
||||||
|
HERO_STATUS_LINE: report.hero.statusLine || '',
|
||||||
|
HERO_STATUS_EMPTY_CLASS: report.hero.statusLine ? '' : 'empty-section',
|
||||||
|
HERO_TAKEAWAY: report.hero.keyTakeaway || '',
|
||||||
|
HERO_TAKEAWAY_EMPTY_CLASS: report.hero.keyTakeaway ? '' : 'empty-section',
|
||||||
|
HERO_PENDING: report.hero.pendingNote || '',
|
||||||
|
HERO_PENDING_EMPTY_CLASS: report.hero.pendingNote ? '' : 'empty-section',
|
||||||
|
HERO_AVATARS: heroAvatars,
|
||||||
|
MESSAGE_COUNT: String(report.summaryStats.messageCount),
|
||||||
|
ACTIVE_USERS: String(report.summaryStats.activeUsers),
|
||||||
|
TOPIC_COUNT: String(report.summaryStats.topicCount),
|
||||||
|
MEDIA_COUNT: String(report.summaryStats.mediaCount),
|
||||||
|
TOPICS_EMPTY_CLASS: report.sectionMeta.topics?.enabled ? '' : 'empty-section',
|
||||||
|
TOPIC_CARDS: topicCards,
|
||||||
|
TOPICS_MORE_NOTE: report.sectionMeta.topics?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
|
||||||
|
IMPORTANT_MESSAGES: importantMessages,
|
||||||
|
MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
ACTIONS_EMPTY_CLASS: report.sectionMeta.actions?.enabled ? '' : 'empty-section',
|
||||||
|
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||||
|
TODO_CARDS: todoCards,
|
||||||
|
UNRESOLVED_EMPTY_CLASS: report.unresolved.length ? '' : 'empty-section',
|
||||||
|
UNRESOLVED_CARDS: unresolvedCards,
|
||||||
|
ACTIONS_MORE_NOTE: report.sectionMeta.actions?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
|
||||||
|
QUOTE_BLOCKS: quoteBlocks,
|
||||||
|
QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
ANALYTICS_EMPTY_CLASS: '',
|
||||||
|
RANK_ITEMS: rankItems,
|
||||||
|
ACTIVITY_TIMELINE: report.analytics.activeTimeline,
|
||||||
|
CONCLUSION_COUNT: String(report.summaryStats.conclusionCount),
|
||||||
|
TODO_COUNT: String(report.summaryStats.todoCount),
|
||||||
|
UNRESOLVED_COUNT: String(report.summaryStats.unresolvedCount),
|
||||||
|
KEYWORDS_EMPTY_CLASS: report.sectionMeta.keywords?.enabled ? '' : 'empty-section',
|
||||||
|
CLOUD_TAGS: cloudTags,
|
||||||
|
KEYWORDS_MORE_NOTE: report.sectionMeta.keywords?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看</div>` : '',
|
||||||
|
RESOURCES_EMPTY_CLASS: report.sectionMeta.resources?.enabled ? '' : 'empty-section',
|
||||||
|
RESOURCE_ITEMS: resourceItems,
|
||||||
|
RESOURCES_MORE_NOTE: '',
|
||||||
|
QA_EMPTY_CLASS: report.sectionMeta.qa?.enabled ? '' : 'empty-section',
|
||||||
|
QA_CARDS: qaCards,
|
||||||
|
QA_MORE_NOTE: '',
|
||||||
|
STORYLINES_EMPTY_CLASS: report.sectionMeta.storylines?.enabled ? '' : 'empty-section',
|
||||||
|
STORYLINE_CARDS: storylineCards,
|
||||||
|
STORYLINES_MORE_NOTE: '',
|
||||||
|
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: '',
|
||||||
|
VOICE_RANK_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
|
||||||
|
VOICE_RANK_CARDS: voiceRankCards,
|
||||||
|
BADGES_EMPTY_CLASS: report.sectionMeta.badges?.enabled ? '' : 'empty-section',
|
||||||
|
BADGE_CARDS: badgeCards,
|
||||||
|
BADGES_MORE_NOTE: '',
|
||||||
|
CHAINS_EMPTY_CLASS: report.sectionMeta.chains?.enabled ? '' : 'empty-section',
|
||||||
|
CHAIN_CARDS: chainCards,
|
||||||
|
CHAINS_MORE_NOTE: '',
|
||||||
|
GENERATED_AT: metadata.generatedAt,
|
||||||
|
FOOTER_NOTE: metadata.footerNote
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(replaceMap)) html = replacePlaceholder(html, key, value)
|
||||||
|
const htmlPath = path.join(outputDir, `${targetBase}.html`)
|
||||||
|
const pngPath = path.join(outputDir, `${targetBase}.png`)
|
||||||
|
await fs.ensureDir(outputDir)
|
||||||
|
await fs.writeFile(htmlPath, html, 'utf8')
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 430,
|
||||||
|
height: 800,
|
||||||
|
frame: false,
|
||||||
|
backgroundColor: '#f3f5f7',
|
||||||
|
webPreferences: { sandbox: true }
|
||||||
|
})
|
||||||
|
await win.loadFile(htmlPath)
|
||||||
|
await win.webContents.executeJavaScript(`Promise.all([
|
||||||
|
document.fonts.ready,
|
||||||
|
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||||
|
img.addEventListener('load', resolve, { once: true });
|
||||||
|
img.addEventListener('error', resolve, { once: true });
|
||||||
|
}))
|
||||||
|
])`)
|
||||||
|
win.webContents.debugger.attach('1.3')
|
||||||
|
const metrics = await win.webContents.debugger.sendCommand('Page.getLayoutMetrics')
|
||||||
|
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||||
|
const height = Math.ceil(metrics.cssContentSize.height)
|
||||||
|
const screenshot = await win.webContents.debugger.sendCommand('Page.captureScreenshot', {
|
||||||
|
format: 'png',
|
||||||
|
captureBeyondViewport: true,
|
||||||
|
fromSurface: true,
|
||||||
|
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||||
|
})
|
||||||
|
await fs.writeFile(pngPath, Buffer.from(screenshot.data, 'base64'))
|
||||||
|
if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach()
|
||||||
|
win.destroy()
|
||||||
|
return { htmlPath, pngPath, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
const mode = process.argv.includes('--full') ? 'full' : 'compact'
|
||||||
|
const result =
|
||||||
|
mode === 'full'
|
||||||
|
? await renderRequest(fullRequest, 'full-fixture')
|
||||||
|
: await renderRequest(compactRequest, 'compact-fixture')
|
||||||
|
console.log(JSON.stringify({ mode, result, outputDir }, null, 2))
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 fastclaw-ai
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# WechatExplorer WeChat Connector
|
||||||
|
|
||||||
|
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
|
||||||
|
|
||||||
|
- QR-code login with a single persisted credential
|
||||||
|
- account discovery
|
||||||
|
- inbound long polling and authenticated webhook delivery
|
||||||
|
- local HTTP health and send endpoints
|
||||||
|
- text and local/remote media sending
|
||||||
|
|
||||||
|
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . login --json
|
||||||
|
go run . accounts --json
|
||||||
|
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server provides an HTTP API for sending messages.
|
||||||
|
type Server struct {
|
||||||
|
clients []*ilink.Client
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates an API server.
|
||||||
|
func NewServer(clients []*ilink.Client, addr string) *Server {
|
||||||
|
if addr == "" {
|
||||||
|
addr = "127.0.0.1:18011"
|
||||||
|
}
|
||||||
|
return &Server{clients: clients, addr: addr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRequest is the JSON body for POST /api/send.
|
||||||
|
type SendRequest struct {
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the HTTP server. Blocks until ctx is cancelled.
|
||||||
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/send", s.handleSend)
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintln(w, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{Addr: s.addr, Handler: mux}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("[api] listening on %s", s.addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SendRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.To == "" {
|
||||||
|
http.Error(w, `"to" is required`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Text == "" && req.MediaURL == "" {
|
||||||
|
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.clients) == 0 {
|
||||||
|
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s.clientForAccount(req.AccountID)
|
||||||
|
if client == nil {
|
||||||
|
http.Error(w, "requested account is not available", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Send text if provided
|
||||||
|
if req.Text != "" {
|
||||||
|
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
|
||||||
|
log.Printf("[api] send text failed: %v", err)
|
||||||
|
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
|
||||||
|
|
||||||
|
// Extract and send any markdown images embedded in text
|
||||||
|
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
|
||||||
|
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
|
||||||
|
log.Printf("[api] send extracted image failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send media if provided
|
||||||
|
if req.MediaURL != "" {
|
||||||
|
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
|
||||||
|
log.Printf("[api] send media failed: %v", err)
|
||||||
|
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) clientForAccount(accountID string) *ilink.Client {
|
||||||
|
if accountID == "" {
|
||||||
|
return s.clients[0]
|
||||||
|
}
|
||||||
|
for _, client := range s.clients {
|
||||||
|
if client.BotID() == accountID {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
|
||||||
|
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
|
||||||
|
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
|
||||||
|
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
|
||||||
|
|
||||||
|
if got := server.clientForAccount("bot-new"); got != newClient {
|
||||||
|
t.Fatal("clientForAccount did not select the requested account")
|
||||||
|
}
|
||||||
|
if got := server.clientForAccount("missing"); got != nil {
|
||||||
|
t.Fatal("clientForAccount should reject an unknown account")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
rsc.io/qr v0.2.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
|
||||||
|
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
|
||||||
|
statusWait = "wait"
|
||||||
|
statusScanned = "scaned"
|
||||||
|
statusConfirmed = "confirmed"
|
||||||
|
statusExpired = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchQRCode retrieves a new QR code for login.
|
||||||
|
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
|
||||||
|
c := NewUnauthenticatedClient()
|
||||||
|
var resp QRCodeResponse
|
||||||
|
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch QR code: %w", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollQRStatus polls for QR code scan status until confirmed or expired.
|
||||||
|
// It calls onStatus for each status change so the caller can display progress.
|
||||||
|
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
|
||||||
|
c := NewUnauthenticatedClient()
|
||||||
|
url := qrStatusURL + qrcode
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||||
|
var resp QRStatusResponse
|
||||||
|
err := c.doGet(pollCtx, url, &resp)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Timeout is normal for long-poll, retry
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if onStatus != nil {
|
||||||
|
onStatus(resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch resp.Status {
|
||||||
|
case statusConfirmed:
|
||||||
|
creds := &Credentials{
|
||||||
|
BotToken: resp.BotToken,
|
||||||
|
ILinkBotID: resp.ILinkBotID,
|
||||||
|
BaseURL: resp.BaseURL,
|
||||||
|
ILinkUserID: resp.ILinkUserID,
|
||||||
|
}
|
||||||
|
return creds, nil
|
||||||
|
case statusExpired:
|
||||||
|
return nil, fmt.Errorf("QR code expired")
|
||||||
|
case statusWait, statusScanned:
|
||||||
|
// Continue polling
|
||||||
|
default:
|
||||||
|
// Unknown status, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountsDir returns the directory where account credentials are stored.
|
||||||
|
func AccountsDir() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
|
||||||
|
func NormalizeAccountID(raw string) string {
|
||||||
|
s := raw
|
||||||
|
for _, ch := range []string{"@", ".", ":"} {
|
||||||
|
s = filepath.Clean(s)
|
||||||
|
s = replaceAll(s, ch, "-")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceAll(s, old, new string) string {
|
||||||
|
for {
|
||||||
|
i := indexOf(s, old)
|
||||||
|
if i < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = s[:i] + new + s[i+len(old):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := range s {
|
||||||
|
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCredentials saves the latest credentials and removes older accounts.
|
||||||
|
// The new credential is written first so a failed login never destroys the
|
||||||
|
// previously working credential.
|
||||||
|
func SaveCredentials(creds *Credentials) error {
|
||||||
|
dir, err := AccountsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create accounts dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := NormalizeAccountID(creds.ILinkBotID)
|
||||||
|
path := filepath.Join(dir, id+".json")
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(creds, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prune old credentials: %w", err)
|
||||||
|
}
|
||||||
|
keepPrefix := id + "."
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Ext(entry.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAllCredentials loads all saved account credentials.
|
||||||
|
func LoadAllCredentials() ([]*Credentials, error) {
|
||||||
|
dir, err := AccountsDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read accounts dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*Credentials
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var creds Credentials
|
||||||
|
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
|
||||||
|
result = append(result, &creds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialsPath returns the path for display purposes.
|
||||||
|
func CredentialsPath() (string, error) {
|
||||||
|
return AccountsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
|
||||||
|
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
|
||||||
|
if err := SaveCredentials(old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir, err := AccountsDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SaveCredentials(latest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accounts, err := LoadAllCredentials()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
|
||||||
|
t.Fatalf("accounts = %#v", accounts)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("old sync state still exists: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||||
|
longPollTimeout = 35 * time.Second
|
||||||
|
sendTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is an iLink HTTP API client.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
botToken string
|
||||||
|
botID string
|
||||||
|
httpClient *http.Client
|
||||||
|
wechatUIN string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new iLink API client.
|
||||||
|
func NewClient(creds *Credentials) *Client {
|
||||||
|
baseURL := creds.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultBaseURL
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
botToken: creds.BotToken,
|
||||||
|
botID: creds.ILinkBotID,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
wechatUIN: generateWechatUIN(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUnauthenticatedClient creates a client without credentials for login flow.
|
||||||
|
func NewUnauthenticatedClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: defaultBaseURL,
|
||||||
|
httpClient: &http.Client{Timeout: 40 * time.Second},
|
||||||
|
wechatUIN: generateWechatUIN(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotID returns the bot's user ID.
|
||||||
|
func (c *Client) BotID() string {
|
||||||
|
return c.botID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdates performs a long-poll for new messages.
|
||||||
|
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
|
||||||
|
reqBody := GetUpdatesRequest{
|
||||||
|
GetUpdatesBuf: buf,
|
||||||
|
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var resp GetUpdatesResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message through iLink.
|
||||||
|
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var resp SendMessageResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig fetches bot config for a user (includes typing_ticket).
|
||||||
|
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := GetConfigRequest{
|
||||||
|
ILinkUserID: userID,
|
||||||
|
ContextToken: contextToken,
|
||||||
|
BaseInfo: BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp GetConfigResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTyping sends a typing indicator to a user.
|
||||||
|
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := SendTypingRequest{
|
||||||
|
ILinkUserID: userID,
|
||||||
|
TypingTicket: typingTicket,
|
||||||
|
Status: status,
|
||||||
|
BaseInfo: BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp SendTypingResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Ret != 0 {
|
||||||
|
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadURL gets a pre-signed CDN upload URL for media files.
|
||||||
|
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var resp GetUploadURLResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseURL returns the base URL for CDN operations.
|
||||||
|
func (c *Client) BaseURL() string {
|
||||||
|
return c.baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
c.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(respBody, result); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(respBody, result); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.botToken)
|
||||||
|
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateWechatUIN() string {
|
||||||
|
var n uint32
|
||||||
|
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
|
||||||
|
s := fmt.Sprintf("%d", n)
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxConsecutiveFailures = 5
|
||||||
|
initialBackoff = 3 * time.Second
|
||||||
|
maxBackoff = 60 * time.Second
|
||||||
|
sessionExpiredBackoff = 5 * time.Second
|
||||||
|
errCodeSessionExpired = -14
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageHandler is called for each received message.
|
||||||
|
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
|
||||||
|
|
||||||
|
// Monitor manages the long-poll loop for receiving messages.
|
||||||
|
type Monitor struct {
|
||||||
|
client *Client
|
||||||
|
handler MessageHandler
|
||||||
|
getUpdatesBuf string
|
||||||
|
bufPath string
|
||||||
|
failures int
|
||||||
|
lastActivity time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMonitor creates a new long-poll monitor.
|
||||||
|
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accountID := NormalizeAccountID(client.BotID())
|
||||||
|
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
client: client,
|
||||||
|
handler: handler,
|
||||||
|
bufPath: bufPath,
|
||||||
|
lastActivity: time.Now(),
|
||||||
|
}
|
||||||
|
m.loadBuf()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the long-poll loop. It blocks until ctx is cancelled.
|
||||||
|
// Automatically recovers from errors with exponential backoff.
|
||||||
|
func (m *Monitor) Run(ctx context.Context) error {
|
||||||
|
log.Println("[monitor] starting long-poll loop")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("[monitor] shutting down")
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
m.failures++
|
||||||
|
backoff := m.calcBackoff()
|
||||||
|
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
||||||
|
m.failures, maxConsecutiveFailures, backoff, err)
|
||||||
|
if m.failures == maxConsecutiveFailures {
|
||||||
|
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(backoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failure counter on any successful response
|
||||||
|
m.failures = 0
|
||||||
|
m.lastActivity = time.Now()
|
||||||
|
|
||||||
|
// Session expired — reset sync buf and reconnect silently
|
||||||
|
if resp.ErrCode == errCodeSessionExpired {
|
||||||
|
if m.getUpdatesBuf != "" {
|
||||||
|
log.Printf("[monitor] session expired, resetting sync buf")
|
||||||
|
m.getUpdatesBuf = ""
|
||||||
|
m.saveBuf()
|
||||||
|
} else {
|
||||||
|
// Sync buf already empty but still getting session expired:
|
||||||
|
// the bot token itself has expired. The user needs to re-login.
|
||||||
|
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(sessionExpiredBackoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other server errors
|
||||||
|
if resp.Ret != 0 && resp.ErrCode != 0 {
|
||||||
|
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update buf for next poll
|
||||||
|
if resp.GetUpdatesBuf != "" {
|
||||||
|
m.getUpdatesBuf = resp.GetUpdatesBuf
|
||||||
|
m.saveBuf()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process messages concurrently — don't block the poll loop
|
||||||
|
for _, msg := range resp.Msgs {
|
||||||
|
go m.handler(ctx, m.client, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
|
||||||
|
func (m *Monitor) calcBackoff() time.Duration {
|
||||||
|
d := initialBackoff
|
||||||
|
for i := 1; i < m.failures; i++ {
|
||||||
|
d *= 2
|
||||||
|
if d > maxBackoff {
|
||||||
|
return maxBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncData struct {
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) loadBuf() {
|
||||||
|
data, err := os.ReadFile(m.bufPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var s syncData
|
||||||
|
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
|
||||||
|
m.getUpdatesBuf = s.GetUpdatesBuf
|
||||||
|
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) saveBuf() {
|
||||||
|
dir := filepath.Dir(m.bufPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
log.Printf("[monitor] failed to create buf dir: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
|
||||||
|
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
|
||||||
|
log.Printf("[monitor] failed to save buf: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatMessageSummary returns a short description of a message for logging.
|
||||||
|
func FormatMessageSummary(msg WeixinMessage) string {
|
||||||
|
text := ""
|
||||||
|
for _, item := range msg.ItemList {
|
||||||
|
if item.Type == ItemTypeText && item.TextItem != nil {
|
||||||
|
text = item.TextItem.Text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(text) > 50 {
|
||||||
|
text = text[:50] + "..."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
// Message types
|
||||||
|
const (
|
||||||
|
MessageTypeNone = 0
|
||||||
|
MessageTypeUser = 1
|
||||||
|
MessageTypeBot = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message states
|
||||||
|
const (
|
||||||
|
MessageStateNew = 0
|
||||||
|
MessageStateGenerating = 1
|
||||||
|
MessageStateFinish = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item types
|
||||||
|
const (
|
||||||
|
ItemTypeNone = 0
|
||||||
|
ItemTypeText = 1
|
||||||
|
ItemTypeImage = 2
|
||||||
|
ItemTypeVoice = 3
|
||||||
|
ItemTypeFile = 4
|
||||||
|
ItemTypeVideo = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRCodeResponse is the response from get_bot_qrcode.
|
||||||
|
type QRCodeResponse struct {
|
||||||
|
QRCode string `json:"qrcode"`
|
||||||
|
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRStatusResponse is the response from get_qrcode_status.
|
||||||
|
type QRStatusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ILinkBotID string `json:"ilink_bot_id"`
|
||||||
|
BaseURL string `json:"baseurl"`
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials stores login session data.
|
||||||
|
type Credentials struct {
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ILinkBotID string `json:"ilink_bot_id"`
|
||||||
|
BaseURL string `json:"baseurl"`
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseInfo is included in request bodies.
|
||||||
|
type BaseInfo struct {
|
||||||
|
ChannelVersion string `json:"channel_version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdatesRequest is the body for getupdates.
|
||||||
|
type GetUpdatesRequest struct {
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdatesResponse is the response from getupdates.
|
||||||
|
type GetUpdatesResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrCode int `json:"errcode,omitempty"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
Msgs []WeixinMessage `json:"msgs"`
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeixinMessage represents a message from WeChat.
|
||||||
|
type WeixinMessage struct {
|
||||||
|
Seq int `json:"seq,omitempty"`
|
||||||
|
MessageID int64 `json:"message_id,omitempty"`
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
ToUserID string `json:"to_user_id"`
|
||||||
|
MessageType int `json:"message_type"`
|
||||||
|
MessageState int `json:"message_state"`
|
||||||
|
ItemList []MessageItem `json:"item_list"`
|
||||||
|
ContextToken string `json:"context_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageItem is a single item in a message.
|
||||||
|
type MessageItem struct {
|
||||||
|
Type int `json:"type"`
|
||||||
|
TextItem *TextItem `json:"text_item,omitempty"`
|
||||||
|
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||||
|
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||||
|
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||||
|
FileItem *FileItem `json:"file_item,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDN media type constants.
|
||||||
|
const (
|
||||||
|
CDNMediaTypeImage = 1
|
||||||
|
CDNMediaTypeVideo = 2
|
||||||
|
CDNMediaTypeFile = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetUploadURLRequest is the body for getuploadurl.
|
||||||
|
type GetUploadURLRequest struct {
|
||||||
|
FileKey string `json:"filekey"`
|
||||||
|
MediaType int `json:"media_type"`
|
||||||
|
ToUserID string `json:"to_user_id"`
|
||||||
|
RawSize int `json:"rawsize"`
|
||||||
|
RawFileMD5 string `json:"rawfilemd5"`
|
||||||
|
FileSize int `json:"filesize"`
|
||||||
|
NoNeedThumb bool `json:"no_need_thumb"`
|
||||||
|
AESKey string `json:"aeskey"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadURLResponse is the response from getuploadurl.
|
||||||
|
type GetUploadURLResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
UploadParam string `json:"upload_param"`
|
||||||
|
UploadFullURL string `json:"upload_full_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextItem holds text content.
|
||||||
|
type TextItem struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaInfo holds CDN media reference for uploaded files.
|
||||||
|
type MediaInfo struct {
|
||||||
|
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||||
|
AESKey string `json:"aes_key"` // base64-encoded
|
||||||
|
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceItem holds voice content.
|
||||||
|
type VoiceItem struct {
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
VoiceSize int `json:"voice_size,omitempty"`
|
||||||
|
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
|
||||||
|
BitsPerSample int `json:"bits_per_sample,omitempty"`
|
||||||
|
SampleRate int `json:"sample_rate,omitempty"` // Hz
|
||||||
|
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
|
||||||
|
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageItem holds image content.
|
||||||
|
type ImageItem struct {
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
MidSize int `json:"mid_size,omitempty"` // ciphertext size
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoItem holds video content.
|
||||||
|
type VideoItem struct {
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
VideoSize int `json:"video_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileItem holds file content.
|
||||||
|
type FileItem struct {
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
FileName string `json:"file_name,omitempty"`
|
||||||
|
Len string `json:"len,omitempty"` // plaintext size as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageRequest is the body for sendmessage.
|
||||||
|
type SendMessageRequest struct {
|
||||||
|
Msg SendMsg `json:"msg"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMsg is the message payload for sending.
|
||||||
|
type SendMsg struct {
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
ToUserID string `json:"to_user_id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
MessageType int `json:"message_type"`
|
||||||
|
MessageState int `json:"message_state"`
|
||||||
|
ItemList []MessageItem `json:"item_list"`
|
||||||
|
ContextToken string `json:"context_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageResponse is the response from sendmessage.
|
||||||
|
type SendMessageResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing status constants.
|
||||||
|
const (
|
||||||
|
TypingStatusTyping = 1
|
||||||
|
TypingStatusCancel = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetConfigRequest is the body for getconfig.
|
||||||
|
type GetConfigRequest struct {
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
ContextToken string `json:"context_token,omitempty"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigResponse is the response from getconfig.
|
||||||
|
type GetConfigResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTypingRequest is the body for sendtyping.
|
||||||
|
type SendTypingRequest struct {
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
TypingTicket string `json:"typing_ticket"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTypingResponse is the response from sendtyping.
|
||||||
|
type SendTypingResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||||
|
"rsc.io/qr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginEvent struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
WeChatUserID string `json:"wechat_user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountSummary struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
WeChatUserID string `json:"wechat_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fatal(errors.New("expected one of: login, accounts, start"))
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "login":
|
||||||
|
err = runLogin(os.Args[2:])
|
||||||
|
case "accounts":
|
||||||
|
err = runAccounts(os.Args[2:])
|
||||||
|
case "start":
|
||||||
|
err = runStart(os.Args[2:])
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown command %q", os.Args[1])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signalContext() (context.Context, context.CancelFunc) {
|
||||||
|
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogin(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("login", flag.ContinueOnError)
|
||||||
|
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := signalContext()
|
||||||
|
defer cancel()
|
||||||
|
creds, err := login(ctx, *jsonOutput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !*jsonOutput {
|
||||||
|
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
|
||||||
|
qrResponse, err := ilink.FetchQRCode(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode QR image: %w", err)
|
||||||
|
}
|
||||||
|
emit := func(event loginEvent) {
|
||||||
|
if jsonOutput {
|
||||||
|
_ = json.NewEncoder(os.Stdout).Encode(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
|
||||||
|
lastStatus := ""
|
||||||
|
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
|
||||||
|
if status != lastStatus {
|
||||||
|
lastStatus = status
|
||||||
|
emit(loginEvent{Status: status})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ilink.SaveCredentials(creds); err != nil {
|
||||||
|
return nil, fmt.Errorf("save credentials: %w", err)
|
||||||
|
}
|
||||||
|
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAccounts(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
|
||||||
|
jsonOutput := flags.Bool("json", false, "print JSON")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accounts, err := ilink.LoadAllCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := make([]accountSummary, 0, len(accounts))
|
||||||
|
for _, account := range accounts {
|
||||||
|
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStart(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("start", flag.ContinueOnError)
|
||||||
|
_ = flags.Bool("foreground", false, "kept for host compatibility")
|
||||||
|
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
|
||||||
|
accountID := flags.String("account-id", "", "account to start")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accounts, err := ilink.LoadAllCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
return errors.New("no connected WeChat account; scan a QR code first")
|
||||||
|
}
|
||||||
|
selected := accounts[len(accounts)-1]
|
||||||
|
if *accountID != "" {
|
||||||
|
selected = nil
|
||||||
|
for _, account := range accounts {
|
||||||
|
if account.ILinkBotID == *accountID {
|
||||||
|
selected = account
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected == nil {
|
||||||
|
return fmt.Errorf("account %q not found", *accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signalContext()
|
||||||
|
defer cancel()
|
||||||
|
client := ilink.NewClient(selected)
|
||||||
|
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
|
||||||
|
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
|
||||||
|
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
|
||||||
|
|
||||||
|
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
|
||||||
|
if webhookURL != "" {
|
||||||
|
webhook.Dispatch(messageContext, source, message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
wait.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
log.Printf("[api] stopped: %v", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
log.Printf("[monitor] stopped: %v", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wait.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||||
|
|
||||||
|
// UploadedFile holds the result of a CDN upload.
|
||||||
|
type UploadedFile struct {
|
||||||
|
DownloadParam string // encrypted query param for download
|
||||||
|
AESKeyHex string // hex-encoded AES key
|
||||||
|
FileSize int // plaintext size
|
||||||
|
CipherSize int // ciphertext size
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
|
||||||
|
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
|
||||||
|
// Generate random filekey and AES key
|
||||||
|
filekey := make([]byte, 16)
|
||||||
|
aeskey := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(filekey); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate filekey: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := rand.Read(aeskey); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate aeskey: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filekeyHex := hex.EncodeToString(filekey)
|
||||||
|
aeskeyHex := hex.EncodeToString(aeskey)
|
||||||
|
|
||||||
|
// Calculate MD5 of plaintext
|
||||||
|
hash := md5.Sum(data)
|
||||||
|
rawMD5 := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
// Calculate ciphertext size (PKCS7 padding)
|
||||||
|
cipherSize := aesECBPaddedSize(len(data))
|
||||||
|
|
||||||
|
// Get upload URL from iLink API
|
||||||
|
uploadReq := &ilink.GetUploadURLRequest{
|
||||||
|
FileKey: filekeyHex,
|
||||||
|
MediaType: mediaType,
|
||||||
|
ToUserID: toUserID,
|
||||||
|
RawSize: len(data),
|
||||||
|
RawFileMD5: rawMD5,
|
||||||
|
FileSize: cipherSize,
|
||||||
|
NoNeedThumb: true,
|
||||||
|
AESKey: aeskeyHex,
|
||||||
|
BaseInfo: ilink.BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get upload URL: %w", err)
|
||||||
|
}
|
||||||
|
if uploadResp.Ret != 0 {
|
||||||
|
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt data with AES-128-ECB
|
||||||
|
encrypted, err := encryptAESECB(data, aeskey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
|
||||||
|
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
|
||||||
|
if cdnURL == "" {
|
||||||
|
if uploadResp.UploadParam == "" {
|
||||||
|
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
|
||||||
|
}
|
||||||
|
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
|
||||||
|
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CDN upload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UploadedFile{
|
||||||
|
DownloadParam: downloadParam,
|
||||||
|
AESKeyHex: aeskeyHex,
|
||||||
|
FileSize: len(data),
|
||||||
|
CipherSize: cipherSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
|
||||||
|
func AESKeyToBase64(hexKey string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(hexKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
|
||||||
|
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
|
||||||
|
// Decode AES key: base64 -> hex string -> raw bytes
|
||||||
|
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode AES key base64: %w", err)
|
||||||
|
}
|
||||||
|
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode AES key hex: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download encrypted data from CDN
|
||||||
|
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
|
||||||
|
cdnBaseURL, url.QueryEscape(encryptQueryParam))
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download from CDN: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read CDN response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt AES-128-ECB
|
||||||
|
return decryptAESECB(encrypted, aesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
|
||||||
|
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ciphertext)%aes.BlockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := make([]byte, len(ciphertext))
|
||||||
|
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||||
|
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove PKCS7 padding
|
||||||
|
if len(plaintext) == 0 {
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
padLen := int(plaintext[len(plaintext)-1])
|
||||||
|
if padLen > aes.BlockSize || padLen == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
return plaintext[:len(plaintext)-padLen], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadParam := resp.Header.Get("X-Encrypted-Param")
|
||||||
|
if downloadParam == "" {
|
||||||
|
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadParam, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
|
||||||
|
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKCS7 padding
|
||||||
|
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
|
||||||
|
padded := make([]byte, len(plaintext)+padLen)
|
||||||
|
copy(padded, plaintext)
|
||||||
|
for i := len(plaintext); i < len(padded); i++ {
|
||||||
|
padded[i] = byte(padLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECB mode: encrypt each block independently
|
||||||
|
encrypted := make([]byte, len(padded))
|
||||||
|
for i := 0; i < len(padded); i += aes.BlockSize {
|
||||||
|
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
|
||||||
|
}
|
||||||
|
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesECBPaddedSize(plaintextSize int) int {
|
||||||
|
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
webhookAttempts = 3
|
||||||
|
webhookTimeout = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type InboundWebhook struct {
|
||||||
|
url string
|
||||||
|
token string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboundWebhookPayload struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
MessageType int `json:"message_type"`
|
||||||
|
Items []inboundWebhookItem `json:"items"`
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboundWebhookItem struct {
|
||||||
|
Type int `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInboundWebhook(url, token string) *InboundWebhook {
|
||||||
|
return &InboundWebhook{
|
||||||
|
url: strings.TrimSpace(url),
|
||||||
|
token: token,
|
||||||
|
client: &http.Client{Timeout: webhookTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
|
||||||
|
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
|
||||||
|
payload := normalizeInboundMessage(client.BotID(), msg)
|
||||||
|
go func() {
|
||||||
|
if err := w.deliver(ctx, payload); err != nil {
|
||||||
|
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode payload: %w", err)
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= webhookAttempts; attempt++ {
|
||||||
|
if attempt > 1 {
|
||||||
|
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
|
||||||
|
if reqErr != nil {
|
||||||
|
return fmt.Errorf("create request: %w", reqErr)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if w.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+w.token)
|
||||||
|
}
|
||||||
|
resp, doErr := w.client.Do(req)
|
||||||
|
if doErr != nil {
|
||||||
|
lastErr = doErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
|
||||||
|
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
|
||||||
|
for _, item := range msg.ItemList {
|
||||||
|
normalized := inboundWebhookItem{Type: item.Type}
|
||||||
|
if item.TextItem != nil {
|
||||||
|
normalized.Text = item.TextItem.Text
|
||||||
|
} else if item.VoiceItem != nil {
|
||||||
|
normalized.Text = item.VoiceItem.Text
|
||||||
|
}
|
||||||
|
items = append(items, normalized)
|
||||||
|
}
|
||||||
|
return inboundWebhookPayload{
|
||||||
|
AccountID: accountID,
|
||||||
|
FromUserID: msg.FromUserID,
|
||||||
|
MessageID: msg.MessageID,
|
||||||
|
MessageType: msg.MessageType,
|
||||||
|
Items: items,
|
||||||
|
ReceivedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
|
||||||
|
var got inboundWebhookPayload
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||||
|
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||||
|
t.Errorf("decode: %v", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
webhook := NewInboundWebhook(server.URL, "secret")
|
||||||
|
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
|
||||||
|
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
|
||||||
|
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deliver: %v", err)
|
||||||
|
}
|
||||||
|
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
|
||||||
|
t.Fatalf("payload = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if calls.Add(1) < 3 {
|
||||||
|
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
webhook := NewInboundWebhook(server.URL, "")
|
||||||
|
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
|
||||||
|
t.Fatalf("deliver: %v", err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 3 {
|
||||||
|
t.Fatalf("calls = %d, want 3", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
webhook := NewInboundWebhook(server.URL, "")
|
||||||
|
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
|
||||||
|
t.Fatal("deliver error = nil")
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("calls = %d, want 1", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Code blocks: strip fences, keep code content
|
||||||
|
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
|
||||||
|
// Inline code: strip backticks, keep content
|
||||||
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||||
|
// Images: remove entirely
|
||||||
|
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
|
||||||
|
// Links: keep display text only
|
||||||
|
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
|
||||||
|
// Table separator rows: remove
|
||||||
|
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
|
||||||
|
// Table rows: convert pipe-delimited to space-delimited
|
||||||
|
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
|
||||||
|
// Headers: remove # prefix
|
||||||
|
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
|
||||||
|
// Italic: *text* or _text_
|
||||||
|
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
|
||||||
|
// Strikethrough: ~~text~~
|
||||||
|
reStrike = regexp.MustCompile(`~~(.+?)~~`)
|
||||||
|
// Blockquote: > prefix
|
||||||
|
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
|
||||||
|
// Horizontal rule
|
||||||
|
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
|
||||||
|
// Unordered list markers: -, *, +
|
||||||
|
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
|
||||||
|
func MarkdownToPlainText(text string) string {
|
||||||
|
result := text
|
||||||
|
|
||||||
|
// Code blocks: strip fences, keep code content
|
||||||
|
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
parts := reCodeBlock.FindStringSubmatch(match)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// Images: remove entirely
|
||||||
|
result = reImage.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Links: keep display text only
|
||||||
|
result = reLink.ReplaceAllString(result, "$1")
|
||||||
|
|
||||||
|
// Table separator rows: remove
|
||||||
|
result = reTableSep.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Table rows: pipe-delimited to space-delimited
|
||||||
|
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
parts := reTableRow.FindStringSubmatch(match)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
cells := strings.Split(parts[1], "|")
|
||||||
|
for i := range cells {
|
||||||
|
cells[i] = strings.TrimSpace(cells[i])
|
||||||
|
}
|
||||||
|
return strings.Join(cells, " ")
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// Headers: remove # prefix
|
||||||
|
result = reHeader.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
parts := reBold.FindStringSubmatch(match)
|
||||||
|
if parts[1] != "" {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return parts[2]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strikethrough
|
||||||
|
result = reStrike.ReplaceAllString(result, "$1")
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
result = reBlockquote.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Horizontal rule -> empty line
|
||||||
|
result = reHR.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Unordered list: replace markers with "• "
|
||||||
|
result = reUL.ReplaceAllString(result, "${1}• ")
|
||||||
|
|
||||||
|
// Inline code: strip backticks (do after code blocks)
|
||||||
|
result = reInlineCode.ReplaceAllString(result, "$1")
|
||||||
|
|
||||||
|
// Clean up excessive blank lines
|
||||||
|
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
|
||||||
|
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reMarkdownImage matches markdown image syntax: 
|
||||||
|
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
|
||||||
|
|
||||||
|
// ExtractImageURLs extracts image URLs from markdown text.
|
||||||
|
func ExtractImageURLs(text string) []string {
|
||||||
|
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
|
||||||
|
var urls []string
|
||||||
|
for _, m := range matches {
|
||||||
|
url := strings.TrimSpace(m[1])
|
||||||
|
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
||||||
|
urls = append(urls, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
|
||||||
|
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
|
||||||
|
// Check if it's a local file
|
||||||
|
if _, err := os.Stat(mediaURL); err == nil {
|
||||||
|
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
|
||||||
|
}
|
||||||
|
// Must be a valid HTTP URL to download
|
||||||
|
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
|
||||||
|
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
|
||||||
|
}
|
||||||
|
data, contentType, err := downloadFile(ctx, mediaURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download %s: %w", mediaURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMediaFromPath reads a local file and sends it as a media message.
|
||||||
|
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
cdnMediaType, itemType := classifyMedia(contentType, source)
|
||||||
|
|
||||||
|
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
|
||||||
|
|
||||||
|
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upload to CDN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
media := &ilink.MediaInfo{
|
||||||
|
EncryptQueryParam: uploaded.DownloadParam,
|
||||||
|
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
|
||||||
|
EncryptType: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
var item ilink.MessageItem
|
||||||
|
switch itemType {
|
||||||
|
case ilink.ItemTypeImage:
|
||||||
|
item = ilink.MessageItem{
|
||||||
|
Type: ilink.ItemTypeImage,
|
||||||
|
ImageItem: &ilink.ImageItem{
|
||||||
|
Media: media,
|
||||||
|
MidSize: uploaded.CipherSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case ilink.ItemTypeVideo:
|
||||||
|
item = ilink.MessageItem{
|
||||||
|
Type: ilink.ItemTypeVideo,
|
||||||
|
VideoItem: &ilink.VideoItem{
|
||||||
|
Media: media,
|
||||||
|
VideoSize: uploaded.CipherSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
item = ilink.MessageItem{
|
||||||
|
Type: ilink.ItemTypeFile,
|
||||||
|
FileItem: &ilink.FileItem{
|
||||||
|
Media: media,
|
||||||
|
FileName: fileName,
|
||||||
|
Len: fmt.Sprintf("%d", uploaded.FileSize),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &ilink.SendMessageRequest{
|
||||||
|
Msg: ilink.SendMsg{
|
||||||
|
FromUserID: client.BotID(),
|
||||||
|
ToUserID: toUserID,
|
||||||
|
ClientID: NewClientID(),
|
||||||
|
MessageType: ilink.MessageTypeBot,
|
||||||
|
MessageState: ilink.MessageStateFinish,
|
||||||
|
ItemList: []ilink.MessageItem{item},
|
||||||
|
ContextToken: contextToken,
|
||||||
|
},
|
||||||
|
BaseInfo: ilink.BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.SendMessage(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send media message: %w", err)
|
||||||
|
}
|
||||||
|
if resp.Ret != 0 {
|
||||||
|
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = inferContentType(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
|
||||||
|
ct := strings.ToLower(contentType)
|
||||||
|
|
||||||
|
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
|
||||||
|
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
|
||||||
|
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
|
||||||
|
}
|
||||||
|
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageExt(url string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||||
|
switch ext {
|
||||||
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVideoExt(url string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferContentType(url string) string {
|
||||||
|
ext := filepath.Ext(stripQuery(url))
|
||||||
|
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
func filenameFromURL(rawURL string) string {
|
||||||
|
u := stripQuery(rawURL)
|
||||||
|
name := filepath.Base(u)
|
||||||
|
if name == "" || name == "." || name == "/" {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripQuery(rawURL string) string {
|
||||||
|
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
|
||||||
|
return rawURL[:i]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractImageURLs(t *testing.T) {
|
||||||
|
text := "check  and "
|
||||||
|
urls := ExtractImageURLs(text)
|
||||||
|
if len(urls) != 2 {
|
||||||
|
t.Fatalf("expected 2 urls, got %d", len(urls))
|
||||||
|
}
|
||||||
|
if urls[0] != "https://example.com/a.png" {
|
||||||
|
t.Errorf("urls[0] = %q", urls[0])
|
||||||
|
}
|
||||||
|
if urls[1] != "https://example.com/b.jpg" {
|
||||||
|
t.Errorf("urls[1] = %q", urls[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractImageURLs_NoImages(t *testing.T) {
|
||||||
|
urls := ExtractImageURLs("just plain text")
|
||||||
|
if len(urls) != 0 {
|
||||||
|
t.Errorf("expected 0 urls, got %d", len(urls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractImageURLs_RelativeURL(t *testing.T) {
|
||||||
|
text := ""
|
||||||
|
urls := ExtractImageURLs(text)
|
||||||
|
if len(urls) != 0 {
|
||||||
|
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilenameFromURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/photo.png", "photo.png"},
|
||||||
|
{"https://example.com/path/to/report.pdf", "report.pdf"},
|
||||||
|
{"https://example.com/file", "file"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := filenameFromURL(tt.url)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilenameFromURL_WithQuery(t *testing.T) {
|
||||||
|
got := filenameFromURL("https://example.com/photo.png?token=abc")
|
||||||
|
if got != "photo.png" {
|
||||||
|
t.Errorf("got %q, want %q", got, "photo.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripQuery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/a?b=c", "https://example.com/a"},
|
||||||
|
{"https://example.com/a", "https://example.com/a"},
|
||||||
|
{"https://example.com/?x=1&y=2", "https://example.com/"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := stripQuery(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewClientID generates a new unique client ID for message correlation.
|
||||||
|
func NewClientID() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
|
||||||
|
// It first fetches a typing_ticket via getconfig, then sends the typing status.
|
||||||
|
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
|
||||||
|
// Get typing ticket
|
||||||
|
configResp, err := client.GetConfig(ctx, userID, contextToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get config for typing: %w", err)
|
||||||
|
}
|
||||||
|
if configResp.TypingTicket == "" {
|
||||||
|
return fmt.Errorf("no typing_ticket returned from getconfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send typing
|
||||||
|
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
|
||||||
|
return fmt.Errorf("send typing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[sender] sent typing indicator to %s", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTextReply sends a text reply to a user through the iLink API.
|
||||||
|
// If clientID is empty, a new one is generated.
|
||||||
|
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
|
||||||
|
if clientID == "" {
|
||||||
|
clientID = NewClientID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert markdown to plain text for WeChat display
|
||||||
|
plainText := MarkdownToPlainText(text)
|
||||||
|
|
||||||
|
req := &ilink.SendMessageRequest{
|
||||||
|
Msg: ilink.SendMsg{
|
||||||
|
FromUserID: client.BotID(),
|
||||||
|
ToUserID: toUserID,
|
||||||
|
ClientID: clientID,
|
||||||
|
MessageType: ilink.MessageTypeBot,
|
||||||
|
MessageState: ilink.MessageStateFinish,
|
||||||
|
ItemList: []ilink.MessageItem{
|
||||||
|
{
|
||||||
|
Type: ilink.ItemTypeText,
|
||||||
|
TextItem: &ilink.TextItem{
|
||||||
|
Text: plainText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ContextToken: contextToken,
|
||||||
|
},
|
||||||
|
BaseInfo: ilink.BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.SendMessage(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Ret != 0 {
|
||||||
|
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { app, shell } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import { isPackagedRuntime } from './runtime-mode'
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||||
|
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
||||||
|
|
||||||
|
const sanitize = (value: unknown, depth = 0): unknown => {
|
||||||
|
if (depth > 4) return '[depth-limited]'
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||||
|
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||||
|
.slice(0, 2000)
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||||
|
key,
|
||||||
|
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppLogger {
|
||||||
|
private get logDir(): string {
|
||||||
|
return app.getPath('logs')
|
||||||
|
}
|
||||||
|
|
||||||
|
get logPath(): string {
|
||||||
|
return path.join(this.logDir, 'wechatexplorer.log')
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotateIfNeeded(): void {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
|
||||||
|
const previous = `${this.logPath}.1`
|
||||||
|
if (fs.existsSync(previous)) fs.removeSync(previous)
|
||||||
|
fs.moveSync(this.logPath, previous)
|
||||||
|
} catch {
|
||||||
|
// Logging must never interrupt the application.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write(entry: AppLogEntry): void {
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(this.logDir)
|
||||||
|
this.rotateIfNeeded()
|
||||||
|
const record = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
mode: isPackagedRuntime() ? 'packaged' : 'development',
|
||||||
|
level: entry.level,
|
||||||
|
scope: String(entry.scope || 'app').slice(0, 80),
|
||||||
|
message: String(entry.message || '').slice(0, 500),
|
||||||
|
details: sanitize(entry.details || {})
|
||||||
|
}
|
||||||
|
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||||
|
if (!isPackagedRuntime()) {
|
||||||
|
const method =
|
||||||
|
entry.level === 'error'
|
||||||
|
? console.error
|
||||||
|
: entry.level === 'warn'
|
||||||
|
? console.warn
|
||||||
|
: console.log
|
||||||
|
method(`[${record.scope}] ${record.message}`, record.details)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logging must never interrupt the application.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal(): void {
|
||||||
|
fs.ensureDirSync(this.logDir)
|
||||||
|
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
|
||||||
|
shell.showItemInFolder(this.logPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appLogger = new AppLogger()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// src/main/db/image-insights-store.ts
|
||||||
|
// 持久化 ImageInsight 到 JSON 文件(userData/image-insights.json)
|
||||||
|
// 跟项目现有风格一致(ai-provider-service 用 ai-providers.json)
|
||||||
|
|
||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import type { ImageInsight } from '../../shared/image-insight'
|
||||||
|
|
||||||
|
interface ImageInsightsFile {
|
||||||
|
version: 1
|
||||||
|
/** imageHash -> ImageInsight 索引(缓存查询 O(1)) */
|
||||||
|
byHash: Record<string, ImageInsight>
|
||||||
|
/** messageId -> imageHash 反向索引(防止同一 message 重复入库) */
|
||||||
|
byMessageId: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FILE: ImageInsightsFile = {
|
||||||
|
version: 1,
|
||||||
|
byHash: {},
|
||||||
|
byMessageId: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageInsightsStore {
|
||||||
|
private cache: ImageInsightsFile | null = null
|
||||||
|
|
||||||
|
private get filePath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'image-insights.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded(): ImageInsightsFile {
|
||||||
|
if (this.cache) return this.cache
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.filePath)) {
|
||||||
|
const raw = fs.readJsonSync(this.filePath) as Partial<ImageInsightsFile>
|
||||||
|
this.cache = {
|
||||||
|
version: 1,
|
||||||
|
byHash: raw.byHash || {},
|
||||||
|
byMessageId: raw.byMessageId || {}
|
||||||
|
}
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ImageInsightsStore] failed to load, fallback to empty:', error)
|
||||||
|
}
|
||||||
|
this.cache = { ...EMPTY_FILE }
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
if (!this.cache) return
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(path.dirname(this.filePath))
|
||||||
|
fs.writeJsonSync(this.filePath, this.cache, { spaces: 2 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ImageInsightsStore] failed to persist:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过 imageHash 查询缓存 */
|
||||||
|
getByHash(imageHash: string): ImageInsight | null {
|
||||||
|
return this.ensureLoaded().byHash[imageHash] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出某会话的所有 insights(按时间倒序) */
|
||||||
|
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||||
|
const data = this.ensureLoaded()
|
||||||
|
const items = Object.values(data.byHash)
|
||||||
|
.filter((it) => it.sessionId === sessionId)
|
||||||
|
.sort((a, b) => b.sentAt - a.sentAt)
|
||||||
|
return typeof limit === 'number' ? items.slice(0, limit) : items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入或更新 Insight。
|
||||||
|
* - 同 imageHash 已存在:更新 description/ocrText/tags/category/importance/provider/model/updatedAt(保留 createdAt)
|
||||||
|
* - 新 hash:插入
|
||||||
|
* 同步维护 byMessageId 反向索引。
|
||||||
|
*/
|
||||||
|
upsert(insight: ImageInsight): void {
|
||||||
|
const data = this.ensureLoaded()
|
||||||
|
const existing = data.byHash[insight.imageHash]
|
||||||
|
const now = Date.now()
|
||||||
|
if (existing) {
|
||||||
|
data.byHash[insight.imageHash] = {
|
||||||
|
...existing,
|
||||||
|
...insight,
|
||||||
|
id: existing.id, // 保留 id
|
||||||
|
createdAt: existing.createdAt, // 保留首次分析时间
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.byHash[insight.imageHash] = { ...insight, createdAt: now, updatedAt: now }
|
||||||
|
data.byMessageId[insight.messageId] = insight.imageHash
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageInsightsStore = new ImageInsightsStore()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Message } from '../shared/types'
|
||||||
|
|
||||||
|
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}`
|
||||||
|
const safe = (value: unknown): string =>
|
||||||
|
String(value ?? '').replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c
|
||||||
|
)
|
||||||
|
|
||||||
|
export function renderExportPage(name: string, messages: Message[]): string {
|
||||||
|
const body = messages
|
||||||
|
.map((m) => {
|
||||||
|
const avatar = m.img
|
||||||
|
? `<img src="${safe(m.img)}" alt="">`
|
||||||
|
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||||
|
const audio = m.voiceDataUrl
|
||||||
|
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
|
||||||
|
: ''
|
||||||
|
const quote =
|
||||||
|
m.contentData?.type === 'quote'
|
||||||
|
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
|
||||||
|
: ''
|
||||||
|
const media =
|
||||||
|
m.exportMediaUrl && m.exportMediaType === 'image'
|
||||||
|
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
|
||||||
|
: m.exportMediaUrl && m.exportMediaType === 'video'
|
||||||
|
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
|
||||||
|
: m.exportMediaUrl && m.exportMediaType === 'sticker'
|
||||||
|
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
|
||||||
|
: ''
|
||||||
|
const avatarMarkup =
|
||||||
|
m.exportShowAvatar === false
|
||||||
|
? ''
|
||||||
|
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||||
|
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||||
|
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||||
|
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { app, BrowserWindow, shell } from 'electron'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import { extname, join } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import * as chat from './services/chat-service'
|
||||||
|
import type {
|
||||||
|
ExportJobProgress,
|
||||||
|
ExportMessageKind,
|
||||||
|
ExportRequest,
|
||||||
|
ExportResult
|
||||||
|
} from '../shared/export'
|
||||||
|
import type { Message } from '../shared/types'
|
||||||
|
import { VoiceService } from './voice-service'
|
||||||
|
import { renderExportPage } from './export-html-template'
|
||||||
|
import { ImageDecryptService } from './image-decrypt-service'
|
||||||
|
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||||
|
import { VideoAssetService } from './video-asset-service'
|
||||||
|
import { StickerService } from './sticker-service'
|
||||||
|
|
||||||
|
const jobs = new Set<string>()
|
||||||
|
const safeFilePart = (value: string): string =>
|
||||||
|
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||||
|
const exportStamp = (): string => {
|
||||||
|
const date = new Date()
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
const imageKeys = new ImageKeyConfigService()
|
||||||
|
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||||
|
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||||
|
if (!match) return null
|
||||||
|
return {
|
||||||
|
extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
|
||||||
|
buffer: Buffer.from(match[2], 'base64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizeAssetExtension = (value: string): string => {
|
||||||
|
const extension = value.toLowerCase().replace(/^\./, '')
|
||||||
|
return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
|
||||||
|
? extension === 'jpeg'
|
||||||
|
? 'jpg'
|
||||||
|
: extension
|
||||||
|
: 'jpg'
|
||||||
|
}
|
||||||
|
const detectAssetExtension = (buffer: Buffer): string | null => {
|
||||||
|
if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
|
||||||
|
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
||||||
|
return 'png'
|
||||||
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
|
||||||
|
if (
|
||||||
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
)
|
||||||
|
return 'webp'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
async function readAvatarAsset(
|
||||||
|
source: string
|
||||||
|
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||||
|
const decoded = decodeDataUrl(source)
|
||||||
|
if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (/^https?:\/\//i.test(source)) {
|
||||||
|
const response = await fetch(source)
|
||||||
|
if (!response.ok) return null
|
||||||
|
const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
|
||||||
|
const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return { extension: detectAssetExtension(buffer) || extension, buffer }
|
||||||
|
}
|
||||||
|
const path = source.startsWith('file://') ? fileURLToPath(source) : source
|
||||||
|
const buffer = await fs.readFile(path)
|
||||||
|
return {
|
||||||
|
extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const kindOf = (message: Message): ExportMessageKind => {
|
||||||
|
const type = message.contentData?.type
|
||||||
|
if (type === 'system' && message.contentData?.pat) return 'text'
|
||||||
|
if (
|
||||||
|
type === 'image' ||
|
||||||
|
type === 'video' ||
|
||||||
|
type === 'voice' ||
|
||||||
|
type === 'sticker' ||
|
||||||
|
type === 'share' ||
|
||||||
|
type === 'location' ||
|
||||||
|
type === 'system'
|
||||||
|
)
|
||||||
|
return type
|
||||||
|
if (message.type === '图片') return 'image'
|
||||||
|
if (message.type === '视频') return 'video'
|
||||||
|
if (message.type === '语音') return 'voice'
|
||||||
|
if (message.type === '表情包') return 'sticker'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
|
|
||||||
|
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||||
|
if (format === 'html') return renderExportPage(name, messages)
|
||||||
|
if (format === 'json')
|
||||||
|
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||||
|
if (format === 'markdown')
|
||||||
|
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
|
||||||
|
return [
|
||||||
|
'时间,发送者,类型,内容',
|
||||||
|
...messages.map((m) =>
|
||||||
|
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
|
||||||
|
)
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runExport(request: ExportRequest, win: BrowserWindow): Promise<ExportResult> {
|
||||||
|
jobs.add(request.jobId)
|
||||||
|
const send = (p: ExportJobProgress): void => {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
const messages = chat
|
||||||
|
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||||
|
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||||
|
for (const message of messages) {
|
||||||
|
message.exportShowAvatar = request.includeAvatars !== false
|
||||||
|
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||||
|
if (mappedName) message.name = mappedName
|
||||||
|
}
|
||||||
|
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||||
|
if (!jobs.has(request.jobId)) {
|
||||||
|
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
|
||||||
|
return { success: false, error: '已取消' }
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: 0,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 15
|
||||||
|
})
|
||||||
|
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||||||
|
await fs.mkdir(root, { recursive: true })
|
||||||
|
const ext = request.format === 'markdown' ? 'md' : request.format
|
||||||
|
const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||||
|
const outputDir = join(root, outputFolder)
|
||||||
|
const outputPath =
|
||||||
|
request.format === 'html'
|
||||||
|
? join(outputDir, 'index.html')
|
||||||
|
: join(root, `${outputFolder}.${ext}`)
|
||||||
|
if (request.format === 'html') {
|
||||||
|
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||||||
|
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||||||
|
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||||
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
const avatarUsernames = Array.from(
|
||||||
|
new Set(
|
||||||
|
messages
|
||||||
|
.map((message) => message.senderId)
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const avatarMap =
|
||||||
|
request.includeAvatars === false
|
||||||
|
? {}
|
||||||
|
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
|
||||||
|
const imageConfig = imageKeys.getConfig()
|
||||||
|
const imageService =
|
||||||
|
client && imageConfig.aesKey
|
||||||
|
? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
|
||||||
|
: null
|
||||||
|
const videoService = client ? new VideoAssetService(client) : null
|
||||||
|
const stickerService = client ? new StickerService(client) : null
|
||||||
|
const exportedAvatars = new Map<string, string>()
|
||||||
|
const voiceService =
|
||||||
|
request.includeMedia && chat.getChatDb()
|
||||||
|
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||||
|
: null
|
||||||
|
if (voiceService) {
|
||||||
|
for (const [index, message] of messages.entries()) {
|
||||||
|
if (
|
||||||
|
kindOf(message) !== 'voice' ||
|
||||||
|
!message.sessionId ||
|
||||||
|
!message.localId ||
|
||||||
|
!message.createTime
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const voice = await voiceService.resolveVoice(
|
||||||
|
message.sessionId,
|
||||||
|
message.localId,
|
||||||
|
message.createTime,
|
||||||
|
message.serverId
|
||||||
|
)
|
||||||
|
if (!voice.success || !voice.data) continue
|
||||||
|
const voiceName = `voice_${index + 1}_${message.localId}.wav`
|
||||||
|
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||||
|
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||||
|
message.voiceDataUrl = `voices/${voiceName}`
|
||||||
|
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [index, message] of messages.entries()) {
|
||||||
|
message.exportShowAvatar = request.includeAvatars !== false
|
||||||
|
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||||||
|
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
|
||||||
|
const avatarBuffer = resolvedAvatar?.buffer || null
|
||||||
|
const avatarExtension = resolvedAvatar?.extension || 'jpg'
|
||||||
|
if (avatarBuffer) {
|
||||||
|
const avatarKey = message.senderId || `message_${index + 1}`
|
||||||
|
let avatarName = exportedAvatars.get(avatarKey)
|
||||||
|
if (!avatarName) {
|
||||||
|
avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
|
||||||
|
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
|
||||||
|
exportedAvatars.set(avatarKey, avatarName)
|
||||||
|
}
|
||||||
|
message.exportAvatarUrl = `avatars/${avatarName}`
|
||||||
|
}
|
||||||
|
if (!request.includeMedia || !message.contentData) {
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: index + 1,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (message.contentData.type === 'image' && imageService) {
|
||||||
|
const file = imageService.findImageFile(
|
||||||
|
message.contentData.md5,
|
||||||
|
message.contentData.datName,
|
||||||
|
{ allowThumbnail: true }
|
||||||
|
)
|
||||||
|
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
|
||||||
|
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
|
||||||
|
if (decoded) {
|
||||||
|
const name = `image_${index + 1}.${decoded.extension}`
|
||||||
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
|
message.exportMediaUrl = `media/${name}`
|
||||||
|
message.exportMediaType = 'image'
|
||||||
|
}
|
||||||
|
} else if (message.contentData.type === 'video' && videoService) {
|
||||||
|
const hashes = [
|
||||||
|
message.contentData.md5,
|
||||||
|
message.contentData.newMd5,
|
||||||
|
message.contentData.rawMd5
|
||||||
|
].filter((value): value is string => Boolean(value))
|
||||||
|
const resolved = videoService.resolve(hashes)
|
||||||
|
const token = resolved.url?.split('/').pop()
|
||||||
|
const source = token ? videoService.pathForToken(token) : undefined
|
||||||
|
if (source) {
|
||||||
|
const name = `video_${index + 1}.mp4`
|
||||||
|
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||||
|
message.exportMediaUrl = `media/${name}`
|
||||||
|
message.exportMediaType = 'video'
|
||||||
|
}
|
||||||
|
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||||
|
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||||
|
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||||
|
const decoded = result.data
|
||||||
|
? decodeDataUrl(result.data)
|
||||||
|
: stickerSource
|
||||||
|
? await readAvatarAsset(stickerSource)
|
||||||
|
: null
|
||||||
|
if (decoded) {
|
||||||
|
const name = `sticker_${index + 1}.${decoded.extension}`
|
||||||
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
|
message.exportMediaUrl = `media/${name}`
|
||||||
|
message.exportMediaType = 'sticker'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: index + 1,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: messages.length,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 90
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'completed',
|
||||||
|
processed: messages.length,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 100,
|
||||||
|
outputPath
|
||||||
|
})
|
||||||
|
return { success: true, outputPath, messageCount: messages.length }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
jobs.delete(request.jobId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function cancelExport(jobId: string): void {
|
||||||
|
jobs.delete(jobId)
|
||||||
|
}
|
||||||
|
export async function revealExport(path: string): Promise<void> {
|
||||||
|
shell.showItemInFolder(path)
|
||||||
|
}
|
||||||
@@ -6,11 +6,29 @@ import {
|
|||||||
GroupReportExportRequest,
|
GroupReportExportRequest,
|
||||||
GroupReportExportResult,
|
GroupReportExportResult,
|
||||||
GroupReportMetadata,
|
GroupReportMetadata,
|
||||||
ReportHeat
|
ReportHeat,
|
||||||
|
ReportSectionMeta
|
||||||
} from '../shared/group-report'
|
} from '../shared/group-report'
|
||||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||||
|
import { imageInsightService } from './services/image-insight-service'
|
||||||
|
|
||||||
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
const 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 candidates = [
|
||||||
|
path.join(process.resourcesPath, 'resources', name),
|
||||||
|
path.join(app.getAppPath(), 'resources', name),
|
||||||
|
path.join(process.cwd(), 'resources', name)
|
||||||
|
]
|
||||||
|
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||||
|
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
const escapeHtml = (value: unknown): string =>
|
const escapeHtml = (value: unknown): string =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
@@ -75,17 +93,6 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const templatePath = (): string => {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
|
|
||||||
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
|
|
||||||
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
|
|
||||||
]
|
|
||||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
|
||||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从群成员快照反推真头像,填进 metadata.avatars。
|
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||||
* - 没传 talker → 跳过(向后兼容)
|
* - 没传 talker → 跳过(向后兼容)
|
||||||
@@ -138,6 +145,26 @@ const heatClass = (heat: ReportHeat): string => {
|
|||||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||||
html.replaceAll(`{{${key}}}`, value)
|
html.replaceAll(`{{${key}}}`, value)
|
||||||
|
|
||||||
|
const sectionMeta = (
|
||||||
|
request: GroupReportExportRequest,
|
||||||
|
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||||
|
): ReportSectionMeta | undefined => request.report.sectionMeta?.[key]
|
||||||
|
|
||||||
|
const sectionClass = (
|
||||||
|
request: GroupReportExportRequest,
|
||||||
|
key: keyof NonNullable<typeof request.report.sectionMeta>,
|
||||||
|
hasContent: boolean
|
||||||
|
): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
|
||||||
|
|
||||||
|
const overflowNote = (
|
||||||
|
request: GroupReportExportRequest,
|
||||||
|
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||||
|
): string => {
|
||||||
|
const meta = sectionMeta(request, key)
|
||||||
|
if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
|
||||||
|
return `<div class="section-more">另有 ${meta.hiddenCount} 条内容,请在完整版中查看</div>`
|
||||||
|
}
|
||||||
|
|
||||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||||
const { report, metadata } = request
|
const { report, metadata } = request
|
||||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||||
@@ -147,6 +174,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||||
)
|
)
|
||||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
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))
|
||||||
|
|
||||||
const avatars = new Map<string, string>()
|
const avatars = new Map<string, string>()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -168,7 +198,38 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||||
<p>${escapeHtml(topic.summary)}</p>
|
<p>${escapeHtml(topic.summary)}</p>
|
||||||
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
|
${
|
||||||
|
topic.conclusions?.length
|
||||||
|
? `<div class="topic-conclusions">${topic.conclusions
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||||
|
.join('')}</div>`
|
||||||
|
: topic.conclusion
|
||||||
|
? `<div class="topic-conclusions"><div class="topic-conclusion">${escapeHtml(topic.conclusion)}</div></div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
topic.image
|
||||||
|
? (() => {
|
||||||
|
// 优先用已有 imageUrl;若有 imageHash(来自 visionGallery),按 hash 取原图
|
||||||
|
let imageUrl = topic.image.imageUrl
|
||||||
|
if (!imageUrl && topic.image.imageHash) {
|
||||||
|
const insight = imageInsightService.getInsight(topic.image.imageHash)
|
||||||
|
if (insight) {
|
||||||
|
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
|
||||||
|
// 走 ImageDecryptService.findImageFile + decryptImageToBase64
|
||||||
|
const decryptService = (globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null; decryptImageToBase64: (p: string) => string | null } }).__imageDecrypt
|
||||||
|
if (decryptService) {
|
||||||
|
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
|
||||||
|
if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!imageUrl) return ''
|
||||||
|
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||||
|
})()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
<div class="participants">${topic.participants
|
<div class="participants">${topic.participants
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(
|
||||||
@@ -213,18 +274,122 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
const qaCards = report.qa
|
const todoCards = (report.todos || [])
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) => `<div class="action-card todo-card">
|
||||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
<b>${escapeHtml(item.task)}</b>
|
||||||
|
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
|
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||||
|
</div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
const unresolvedCards = (report.unresolved || [])
|
||||||
const heatBars = report.analytics.topicHeat
|
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) => `<div class="action-card unresolved-card">
|
||||||
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
|
<b>${escapeHtml(item.question)}</b>
|
||||||
|
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
|
<div class="action-note">${escapeHtml(item.note)}</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 chainCards = (report.participantChains || [])
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="card chain-card">
|
||||||
|
<div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div>
|
||||||
|
<div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>
|
||||||
|
${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}
|
||||||
|
</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('')
|
||||||
|
|
||||||
|
// AI 图片理解结果板块(ImageInsight)
|
||||||
|
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
|
||||||
|
const visionCards = (report.media?.visionGallery || [])
|
||||||
|
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="vision-card">
|
||||||
|
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
||||||
|
<div class="vision-body">
|
||||||
|
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||||
|
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||||
|
${item.ocrText ? `<div class="vision-ocr">📝 ${escapeHtml(item.ocrText)}</div>` : ''}
|
||||||
|
${item.tags.length ? `<div class="vision-tags">${item.tags.map((t) => `<span class="vision-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
||||||
|
<div class="vision-label">AI 图片识别</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="${avatar(item.sender)}" 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('')
|
.join('')
|
||||||
|
|
||||||
@@ -236,6 +401,20 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
|
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
|
||||||
|
const heatBarsHtml = report.analytics.topSpeakers
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((speaker) => {
|
||||||
|
const count = Math.max(0, speaker.count)
|
||||||
|
const width = Math.min(100, count * 12)
|
||||||
|
return `<div class="heat-row">
|
||||||
|
<span class="heat-name">${escapeHtml(speaker.name)}</span>
|
||||||
|
<span class="heat-bar"><i style="width:${width}%"></i></span>
|
||||||
|
<span class="heat-val">${count}</span>
|
||||||
|
</div>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
const cloudTags = report.keywords
|
const cloudTags = report.keywords
|
||||||
.slice(0, 15)
|
.slice(0, 15)
|
||||||
.map(
|
.map(
|
||||||
@@ -244,35 +423,116 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
let html = await fs.readFile(templatePath(), 'utf8')
|
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 summaryStats = report.summaryStats || {
|
||||||
|
messageCount: metadata.messageCount,
|
||||||
|
activeUsers: metadata.activeUsers,
|
||||||
|
topicCount: report.topics.length,
|
||||||
|
mediaCount: metadata.mediaMessageCount || 0,
|
||||||
|
imageCount: metadata.imageCount || 0,
|
||||||
|
voiceCount: metadata.voiceCount || 0,
|
||||||
|
stickerCount: metadata.stickerCount || 0,
|
||||||
|
conclusionCount: 0,
|
||||||
|
todoCount: report.todos.length,
|
||||||
|
unresolvedCount: report.unresolved.length
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||||
|
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||||
RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
|
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||||
OVERVIEW: escapeHtml(report.overview),
|
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
|
||||||
|
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
|
||||||
|
// v2 模板使用的 hero-*
|
||||||
|
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
||||||
|
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
|
||||||
|
HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
|
||||||
|
HERO_PENDING: escapeHtml(report.hero?.pendingNote || ''),
|
||||||
|
HERO_STATUS_LINE: escapeHtml(report.hero?.statusLine || ''),
|
||||||
|
HERO_STATUS_EMPTY_CLASS: report.hero?.statusLine ? '' : 'empty-section',
|
||||||
|
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
|
||||||
|
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
|
||||||
HERO_AVATARS: heroAvatars,
|
HERO_AVATARS: heroAvatars,
|
||||||
MESSAGE_COUNT: String(metadata.messageCount),
|
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||||
ACTIVE_USERS: String(metadata.activeUsers),
|
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||||
TOPIC_COUNT: String(report.topics.length),
|
TOPIC_COUNT: String(summaryStats.topicCount),
|
||||||
|
MEDIA_COUNT: String(summaryStats.mediaCount),
|
||||||
TOPIC_CARDS: topicCards,
|
TOPIC_CARDS: topicCards,
|
||||||
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
TOPICS_EMPTY_CLASS: sectionClass(request, 'topics', report.topics.length > 0),
|
||||||
|
TOPICS_MORE_NOTE: overflowNote(request, 'topics'),
|
||||||
|
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
|
||||||
RESOURCE_ITEMS: resourceItems,
|
RESOURCE_ITEMS: resourceItems,
|
||||||
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
|
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
|
||||||
|
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
|
||||||
IMPORTANT_MESSAGES: importantMessages,
|
IMPORTANT_MESSAGES: importantMessages,
|
||||||
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
|
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
||||||
|
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
||||||
QUOTE_BLOCKS: quoteBlocks,
|
QUOTE_BLOCKS: quoteBlocks,
|
||||||
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
|
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
|
||||||
|
ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
|
||||||
|
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||||
|
TODO_CARDS: todoCards,
|
||||||
|
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
|
||||||
|
UNRESOLVED_CARDS: unresolvedCards,
|
||||||
|
ACTIONS_MORE_NOTE: overflowNote(request, 'actions'),
|
||||||
|
QA_EMPTY_CLASS: sectionClass(request, 'qa', report.qa.length > 0),
|
||||||
QA_CARDS: qaCards,
|
QA_CARDS: qaCards,
|
||||||
HEAT_BARS: heatBars,
|
QA_MORE_NOTE: overflowNote(request, 'qa'),
|
||||||
|
STORYLINES_EMPTY_CLASS: sectionClass(request, 'storylines', report.storylines?.length > 0),
|
||||||
|
STORYLINE_CARDS: storylineCards,
|
||||||
|
STORYLINES_MORE_NOTE: overflowNote(request, 'storylines'),
|
||||||
|
REVERSALS_EMPTY_CLASS: sectionClass(request, 'reversals', report.reversals?.length > 0),
|
||||||
|
REVERSAL_CARDS: reversalCards,
|
||||||
|
REVERSALS_MORE_NOTE: overflowNote(request, 'reversals'),
|
||||||
|
CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
|
||||||
|
CHAIN_CARDS: chainCards,
|
||||||
|
CHAINS_MORE_NOTE: overflowNote(request, 'chains'),
|
||||||
|
// AI 图片识别板块
|
||||||
|
VISION_EMPTY_CLASS: sectionClass(
|
||||||
|
request,
|
||||||
|
'vision',
|
||||||
|
(report.media?.visionGallery?.length ?? 0) > 0
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
|
||||||
|
VOICE_RANK_CARDS: voiceRankCards,
|
||||||
|
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
|
||||||
|
BADGE_CARDS: badgeCards,
|
||||||
|
BADGES_MORE_NOTE: overflowNote(request, 'badges'),
|
||||||
RANK_ITEMS: rankItems,
|
RANK_ITEMS: rankItems,
|
||||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||||
|
CONCLUSION_COUNT: String(summaryStats.conclusionCount),
|
||||||
|
TODO_COUNT: String(summaryStats.todoCount),
|
||||||
|
UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
|
||||||
CLOUD_TAGS: cloudTags,
|
CLOUD_TAGS: cloudTags,
|
||||||
|
KEYWORDS_EMPTY_CLASS: sectionClass(request, 'keywords', report.keywords.length > 0),
|
||||||
|
KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
|
||||||
|
ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true),
|
||||||
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||||
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
FOOTER_NOTE: escapeHtml(metadata.footerNote),
|
||||||
|
// v1 模板独有:从 analytics.topSpeakers 渲染水平条形热度图
|
||||||
|
HEAT_BARS: heatBarsHtml
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||||
|
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||||
|
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +587,8 @@ export const exportGroupReport = async (
|
|||||||
|
|
||||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||||
await fs.ensureDir(outputDir)
|
await fs.ensureDir(outputDir)
|
||||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
|
||||||
|
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
|
||||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||||
const htmlStartedAt = new Date()
|
const htmlStartedAt = new Date()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from './services/chat-service'
|
} from './services/chat-service'
|
||||||
import { exportGroupReport } from './group-report-service'
|
import { exportGroupReport } from './group-report-service'
|
||||||
import { GroupReportExportRequest } from '../shared/group-report'
|
import { GroupReportExportRequest } from '../shared/group-report'
|
||||||
|
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||||
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||||
|
|
||||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||||
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
if (keyword) {
|
if (keyword) {
|
||||||
const lower = keyword.toLowerCase()
|
const lower = keyword.toLowerCase()
|
||||||
groups = groups.filter(
|
groups = groups.filter(
|
||||||
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
|
(c) =>
|
||||||
|
c.m_nsNickName.toLowerCase().includes(lower) ||
|
||||||
|
c.m_nsUsrName.toLowerCase().includes(lower)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||||
@@ -236,13 +240,62 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
try {
|
try {
|
||||||
request = JSON.parse(body) as GroupReportExportRequest
|
request = JSON.parse(body) as GroupReportExportRequest
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
|
return sendError(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
'请求体 JSON 解析失败',
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!request?.report || !request?.metadata) {
|
if (!request?.report || !request?.metadata) {
|
||||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||||
}
|
}
|
||||||
const result = await exportGroupReport(request)
|
const result = await exportGroupReport(request)
|
||||||
sendJson(res, result.success ? 200 : 500, result)
|
sendJson(res, result.success ? 200 : 500, result)
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/agent/group-report': async ({ req, res, body }) => {
|
||||||
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
||||||
|
try {
|
||||||
|
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||||
|
} catch {
|
||||||
|
return sendError(res, 400, '请求体 JSON 解析失败')
|
||||||
|
}
|
||||||
|
const result = await generateAgentGroupReport({
|
||||||
|
group: request.group || '',
|
||||||
|
range: request.range
|
||||||
|
})
|
||||||
|
sendJson(res, result.success ? 200 : 400, result)
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/agent/status': ({ res }) => {
|
||||||
|
const status = agentHubService.getStatus()
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: status.hub === 'online' && status.connector === 'online',
|
||||||
|
hub: status.hub,
|
||||||
|
connector: status.connector,
|
||||||
|
dataApi: status.dataApi,
|
||||||
|
databaseReady: status.databaseReady,
|
||||||
|
accountId: status.accountId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/agent/send': async ({ req, res, body }) => {
|
||||||
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||||
|
let request: { to?: string; text?: string; media_url?: string }
|
||||||
|
try {
|
||||||
|
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||||
|
} catch {
|
||||||
|
return sendError(res, 400, '请求体 JSON 解析失败')
|
||||||
|
}
|
||||||
|
const result = await agentHubService.testSend({
|
||||||
|
to: request.to,
|
||||||
|
text: request.text,
|
||||||
|
mediaUrl: request.media_url
|
||||||
|
})
|
||||||
|
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +364,11 @@ export interface ApiServerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let singleton: HttpServerHandle | null = null
|
let singleton: HttpServerHandle | null = null
|
||||||
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
|
let singletonState: ApiServerState = {
|
||||||
|
running: false,
|
||||||
|
host: DEFAULT_HTTP_HOST,
|
||||||
|
port: DEFAULT_HTTP_PORT
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
@@ -326,7 +383,10 @@ export const apiServer = {
|
|||||||
return { ...singletonState }
|
return { ...singletonState }
|
||||||
},
|
},
|
||||||
|
|
||||||
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
|
async start(
|
||||||
|
host: string = DEFAULT_HTTP_HOST,
|
||||||
|
port: number = DEFAULT_HTTP_PORT
|
||||||
|
): Promise<ApiServerState> {
|
||||||
if (singleton) {
|
if (singleton) {
|
||||||
return this.getState()
|
return this.getState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ import crypto from 'crypto'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
export class ImageDecryptService {
|
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
const imageDecryptLog = (...args: unknown[]): void => {
|
||||||
|
if (imageDecryptDebugEnabled) console.log(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecodedImage = {
|
||||||
|
data: string
|
||||||
|
filePath: string
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||||
|
|
||||||
|
export class ImageDecryptService {
|
||||||
private xorKey: number = 0
|
private xorKey: number = 0
|
||||||
private aesKey: string = ''
|
private aesKey: string = ''
|
||||||
private wcdb4Client: Wcdb4Client | null = null
|
private wcdb4Client: Wcdb4Client | null = null
|
||||||
|
private accountDirResolved = false
|
||||||
|
private cachedAccountDir: string | null = null
|
||||||
|
private imagePathCache = new Map<string, string>()
|
||||||
|
private decodedImageCache = new Map<string, DecodedImage>()
|
||||||
|
private decodedImageCacheBytes = 0
|
||||||
|
|
||||||
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
||||||
// 解析 XOR Key (支持 0x40 或 64 格式)
|
// 解析 XOR Key (支持 0x40 或 64 格式)
|
||||||
@@ -29,9 +45,13 @@ export class ImageDecryptService {
|
|||||||
* 获取账号目录
|
* 获取账号目录
|
||||||
*/
|
*/
|
||||||
private getAccountDir(): string | null {
|
private getAccountDir(): string | null {
|
||||||
|
if (this.accountDirResolved) return this.cachedAccountDir
|
||||||
|
this.accountDirResolved = true
|
||||||
|
|
||||||
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
||||||
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
||||||
return wcdbAccountRoot
|
this.cachedAccountDir = wcdbAccountRoot
|
||||||
|
return this.cachedAccountDir
|
||||||
}
|
}
|
||||||
|
|
||||||
const homeDir = os.homedir()
|
const homeDir = os.homedir()
|
||||||
@@ -41,7 +61,7 @@ export class ImageDecryptService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!existsSync(accountRoot)) {
|
if (!existsSync(accountRoot)) {
|
||||||
console.log('[ImageDecrypt] account root not found:', accountRoot)
|
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,12 +81,13 @@ export class ImageDecryptService {
|
|||||||
.sort((a, b) => b.mtime - a.mtime)
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
console.log('[ImageDecrypt] no accounts found')
|
imageDecryptLog('[ImageDecrypt] no accounts found')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回最新的账号目录
|
// 返回最新的账号目录
|
||||||
return join(accountRoot, accounts[0].name)
|
this.cachedAccountDir = join(accountRoot, accounts[0].name)
|
||||||
|
return this.cachedAccountDir
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,15 +96,33 @@ export class ImageDecryptService {
|
|||||||
findImageFile(
|
findImageFile(
|
||||||
md5?: string,
|
md5?: string,
|
||||||
imageDatName?: string,
|
imageDatName?: string,
|
||||||
options?: { allowThumbnail?: boolean }
|
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
|
||||||
): string | null {
|
): string | null {
|
||||||
const accountDir = this.getAccountDir()
|
|
||||||
if (!accountDir) return null
|
|
||||||
const allowThumbnail = options?.allowThumbnail !== false
|
const allowThumbnail = options?.allowThumbnail !== false
|
||||||
|
|
||||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||||
console.log('[ImageDecrypt] findImageFile:', {
|
const pathCacheKey = [
|
||||||
|
normalizedMd5,
|
||||||
|
normalizedDatName,
|
||||||
|
allowThumbnail ? 'thumb' : 'original',
|
||||||
|
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||||
|
options?.accountDir || ''
|
||||||
|
].join('|')
|
||||||
|
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||||
|
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||||
|
|
||||||
|
const rememberPath = (path: string | null): string | null => {
|
||||||
|
if (path) this.imagePathCache.set(pathCacheKey, path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
||||||
|
const accountDir =
|
||||||
|
options?.accountDir && existsSync(options.accountDir)
|
||||||
|
? options.accountDir
|
||||||
|
: this.getAccountDir()
|
||||||
|
if (!accountDir) return null
|
||||||
|
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||||
md5: normalizedMd5,
|
md5: normalizedMd5,
|
||||||
imageDatName: normalizedDatName,
|
imageDatName: normalizedDatName,
|
||||||
accountDir,
|
accountDir,
|
||||||
@@ -94,10 +133,14 @@ export class ImageDecryptService {
|
|||||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||||
if (fullPath && existsSync(fullPath)) {
|
if (fullPath && existsSync(fullPath)) {
|
||||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
const selected = this.getPreferredDatVariantPath(
|
||||||
|
fullPath,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||||
console.log('[ImageDecrypt] hardlink hit:', selected)
|
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||||
return selected
|
return rememberPath(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,29 +148,76 @@ export class ImageDecryptService {
|
|||||||
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||||
const attachDir = join(accountDir, 'msg', 'attach')
|
const attachDir = join(accountDir, 'msg', 'attach')
|
||||||
if (!existsSync(attachDir)) {
|
if (!existsSync(attachDir)) {
|
||||||
console.log('[ImageDecrypt] attach dir not found:', attachDir)
|
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
return rememberPath(
|
||||||
|
this.findImageFileInLegacyDirs(
|
||||||
|
accountDir,
|
||||||
|
normalizedMd5 || normalizedDatName,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||||
if (searchKeys.length === 0) return null
|
if (searchKeys.length === 0) return null
|
||||||
|
|
||||||
for (const key of searchKeys) {
|
for (const key of searchKeys) {
|
||||||
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
|
const directHit = this.fastProbabilisticSearch(
|
||||||
if (directHit) return directHit
|
attachDir,
|
||||||
|
key,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
if (directHit) return rememberPath(directHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
const legacyHit = this.findImageFileInLegacyDirs(
|
||||||
if (legacyHit) return legacyHit
|
accountDir,
|
||||||
|
searchKeys[0],
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
if (legacyHit) return rememberPath(legacyHit)
|
||||||
|
|
||||||
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCachedDecodedImage(key: string): DecodedImage | null {
|
||||||
|
const cached = this.decodedImageCache.get(key)
|
||||||
|
if (!cached) return null
|
||||||
|
this.decodedImageCache.delete(key)
|
||||||
|
this.decodedImageCache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheDecodedImage(key: string, image: DecodedImage): void {
|
||||||
|
const size = image.data.length * 2
|
||||||
|
const previous = this.decodedImageCache.get(key)
|
||||||
|
if (previous) {
|
||||||
|
this.decodedImageCacheBytes -= previous.data.length * 2
|
||||||
|
this.decodedImageCache.delete(key)
|
||||||
|
}
|
||||||
|
this.decodedImageCache.set(key, image)
|
||||||
|
this.decodedImageCacheBytes += size
|
||||||
|
while (
|
||||||
|
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
|
||||||
|
this.decodedImageCache.size > 1
|
||||||
|
) {
|
||||||
|
const oldestKey = this.decodedImageCache.keys().next().value
|
||||||
|
if (!oldestKey) break
|
||||||
|
const oldest = this.decodedImageCache.get(oldestKey)
|
||||||
|
this.decodedImageCache.delete(oldestKey)
|
||||||
|
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fastProbabilisticSearch(
|
private fastProbabilisticSearch(
|
||||||
attachDir: string,
|
attachDir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
@@ -144,9 +234,9 @@ export class ImageDecryptService {
|
|||||||
join(attachDir, dir1, dir2, 'Image', variant),
|
join(attachDir, dir1, dir2, 'Image', variant),
|
||||||
join(attachDir, dir1, dir2, 'image', variant)
|
join(attachDir, dir1, dir2, 'image', variant)
|
||||||
]
|
]
|
||||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
|
||||||
if (found) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,17 +262,18 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const found = this.getLargestExistingPath(
|
const found = this.getLargestExistingPath(
|
||||||
variants.map((variant) => join(imgDir, variant)),
|
variants.map((variant) => join(imgDir, variant)),
|
||||||
allowThumbnail
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
)
|
)
|
||||||
if (found) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] found at:', found)
|
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[ImageDecrypt]遍历目录失败:', e)
|
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -191,7 +282,8 @@ export class ImageDecryptService {
|
|||||||
private findImageFileInLegacyDirs(
|
private findImageFileInLegacyDirs(
|
||||||
accountDir: string,
|
accountDir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
@@ -203,7 +295,7 @@ export class ImageDecryptService {
|
|||||||
].filter((root) => existsSync(root))
|
].filter((root) => existsSync(root))
|
||||||
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
|
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,30 +306,52 @@ export class ImageDecryptService {
|
|||||||
dir: string,
|
dir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
depth: number,
|
depth: number,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
if (depth < 0) return null
|
if (depth < 0) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const variantNames = this.buildPreferredDatNames(datName).filter(
|
||||||
|
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||||
|
)
|
||||||
const variants = new Set(
|
const variants = new Set(
|
||||||
this.buildPreferredDatNames(datName).filter(
|
preferThumbnail
|
||||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
? [
|
||||||
)
|
...variantNames.filter((name) => this.isThumbnailName(name)),
|
||||||
|
...variantNames.filter((name) => !this.isThumbnailName(name))
|
||||||
|
]
|
||||||
|
: variantNames
|
||||||
)
|
)
|
||||||
const entries = readdirSync(dir)
|
const entries = readdirSync(dir)
|
||||||
|
const matchingFiles: string[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
const stat = statSync(fullPath)
|
const stat = statSync(fullPath)
|
||||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||||
console.log('[ImageDecrypt] legacy path hit:', fullPath)
|
matchingFiles.push(fullPath)
|
||||||
return fullPath
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const preferredFile = this.getLargestExistingPath(
|
||||||
|
matchingFiles,
|
||||||
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
|
)
|
||||||
|
if (preferredFile) {
|
||||||
|
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
|
||||||
|
return preferredFile
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
if (!statSync(fullPath).isDirectory()) continue
|
if (!statSync(fullPath).isDirectory()) continue
|
||||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
|
const found = this.recursiveFindDat(
|
||||||
|
fullPath,
|
||||||
|
datName,
|
||||||
|
depth - 1,
|
||||||
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
|
)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -252,13 +366,13 @@ export class ImageDecryptService {
|
|||||||
*/
|
*/
|
||||||
decryptImage(datPath: string): Buffer | null {
|
decryptImage(datPath: string): Buffer | null {
|
||||||
if (!existsSync(datPath)) {
|
if (!existsSync(datPath)) {
|
||||||
console.log('[ImageDecrypt] file not found:', datPath)
|
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const version = this.getDatVersion(datPath)
|
const version = this.getDatVersion(datPath)
|
||||||
console.log(
|
imageDecryptLog(
|
||||||
'[ImageDecrypt] dat version:',
|
'[ImageDecrypt] dat version:',
|
||||||
version,
|
version,
|
||||||
'file:',
|
'file:',
|
||||||
@@ -268,26 +382,24 @@ export class ImageDecryptService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let decrypted: Buffer
|
let decrypted: Buffer
|
||||||
if (version === 1) {
|
if (version === 2) {
|
||||||
console.log('[ImageDecrypt] using V1 (default AES key)')
|
// WeChat 4.0 标准 dat 头: 07 08 56 32 08 07
|
||||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
|
||||||
decrypted = this.decryptDatV4(datPath, key)
|
|
||||||
} else if (version === 2) {
|
|
||||||
console.log('[ImageDecrypt] using V2 (user AES key)')
|
|
||||||
if (!this.aesKey) {
|
if (!this.aesKey) {
|
||||||
console.log('[ImageDecrypt] no AES key configured')
|
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||||
decrypted = this.decryptDatV4(datPath, key)
|
decrypted = this.decryptDatV4(datPath, key)
|
||||||
} else {
|
} else {
|
||||||
console.log('[ImageDecrypt] unsupported dat version:', version)
|
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
|
||||||
|
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return decrypted
|
return decrypted
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ImageDecrypt] decrypt error:', error)
|
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +421,7 @@ export class ImageDecryptService {
|
|||||||
const unwrapped = this.unwrapWxgf(decrypted)
|
const unwrapped = this.unwrapWxgf(decrypted)
|
||||||
const ext = this.detectImageExtension(unwrapped)
|
const ext = this.detectImageExtension(unwrapped)
|
||||||
if (!ext) {
|
if (!ext) {
|
||||||
console.log('[ImageDecrypt] unknown image format')
|
imageDecryptLog('[ImageDecrypt] unknown image format')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +430,41 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检测 DAT 文件版本
|
* 首选 DAT 无法解密时,继续尝试同目录下属于同一图片的其他清晰度变体。
|
||||||
|
* 微信可能只保留 base/_h/_hd/_t 中的一部分,不能把首个文件失败等同于整张图失败。
|
||||||
|
*/
|
||||||
|
decryptImageToBase64WithFallback(
|
||||||
|
datPath: string,
|
||||||
|
allowThumbnail = true
|
||||||
|
): { data: string; filePath: string } | null {
|
||||||
|
const candidates = [datPath]
|
||||||
|
if (extname(datPath).toLowerCase().includes('dat')) {
|
||||||
|
const dir = dirname(datPath)
|
||||||
|
const base = this.normalizeDatBase(basename(datPath))
|
||||||
|
const siblings = this.buildPreferredDatNames(base)
|
||||||
|
.filter((name) => allowThumbnail || !this.isThumbnailName(name))
|
||||||
|
.map((name) => join(dir, name))
|
||||||
|
.filter((candidate) => existsSync(candidate))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftThumb = this.isThumbnailName(basename(left)) ? 1 : 0
|
||||||
|
const rightThumb = this.isThumbnailName(basename(right)) ? 1 : 0
|
||||||
|
if (leftThumb !== rightThumb) return leftThumb - rightThumb
|
||||||
|
return statSync(right).size - statSync(left).size
|
||||||
|
})
|
||||||
|
candidates.push(...siblings)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of this.uniq(candidates)) {
|
||||||
|
const data = this.decryptImageToBase64(candidate)
|
||||||
|
if (data) return { data, filePath: candidate }
|
||||||
|
}
|
||||||
|
imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
|
||||||
|
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
|
||||||
*/
|
*/
|
||||||
private getDatVersion(inputPath: string): number {
|
private getDatVersion(inputPath: string): number {
|
||||||
const bytes = readFileSync(inputPath)
|
const bytes = readFileSync(inputPath)
|
||||||
@@ -327,9 +473,6 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signature = bytes.subarray(0, 6)
|
const signature = bytes.subarray(0, 6)
|
||||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -457,7 +600,11 @@ export class ImageDecryptService {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
private getPreferredDatVariantPath(
|
||||||
|
inputPath: string,
|
||||||
|
allowThumbnail: boolean,
|
||||||
|
preferThumbnail = false
|
||||||
|
): string {
|
||||||
const actualDir = dirname(inputPath)
|
const actualDir = dirname(inputPath)
|
||||||
const base = this.normalizeDatBase(basename(inputPath))
|
const base = this.normalizeDatBase(basename(inputPath))
|
||||||
const variants = this.buildPreferredDatNames(base)
|
const variants = this.buildPreferredDatNames(base)
|
||||||
@@ -466,13 +613,18 @@ export class ImageDecryptService {
|
|||||||
: variants.filter((name) => !this.isThumbnailName(name))
|
: variants.filter((name) => !this.isThumbnailName(name))
|
||||||
const largest = this.getLargestExistingPath(
|
const largest = this.getLargestExistingPath(
|
||||||
ordered.map((variant) => join(actualDir, variant)),
|
ordered.map((variant) => join(actualDir, variant)),
|
||||||
allowThumbnail
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
)
|
)
|
||||||
if (largest) return largest
|
if (largest) return largest
|
||||||
return inputPath
|
return inputPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
|
private getLargestExistingPath(
|
||||||
|
paths: string[],
|
||||||
|
allowThumbnail: boolean,
|
||||||
|
preferThumbnail = false
|
||||||
|
): string | null {
|
||||||
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
||||||
candidates
|
candidates
|
||||||
.filter((candidate) => existsSync(candidate))
|
.filter((candidate) => existsSync(candidate))
|
||||||
@@ -485,7 +637,13 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
.sort((left, right) => right.size - left.size)
|
.sort((left, right) => right.size - left.size)
|
||||||
|
|
||||||
const nonThumb = toSized(paths.filter((candidate) => !this.isThumbnailName(basename(candidate))))
|
const thumbnail = toSized(
|
||||||
|
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
||||||
|
)
|
||||||
|
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||||
|
const nonThumb = toSized(
|
||||||
|
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||||
|
)
|
||||||
if (nonThumb[0]) return nonThumb[0].candidate
|
if (nonThumb[0]) return nonThumb[0].candidate
|
||||||
if (!allowThumbnail) return null
|
if (!allowThumbnail) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import './preload-env'
|
import './preload-env'
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
shell,
|
shell,
|
||||||
@@ -8,14 +8,16 @@ import {
|
|||||||
clipboard,
|
clipboard,
|
||||||
Menu,
|
Menu,
|
||||||
Tray,
|
Tray,
|
||||||
dialog
|
dialog,
|
||||||
|
protocol
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync, promises as fsPromises } from 'fs'
|
||||||
|
import { extname } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { WechatDb } from './wechat-db'
|
import { WechatDb } from './wechat-db'
|
||||||
import { bootstrapWcdbNative } from './wcdb4-client'
|
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
||||||
import { VoiceService } from './voice-service'
|
import { VoiceService } from './voice-service'
|
||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
import { parseMessageContent } from './message-parser'
|
import { parseMessageContent } from './message-parser'
|
||||||
@@ -37,6 +39,14 @@ import type {
|
|||||||
import { DatabaseKeyStore } from './database-key-store'
|
import { DatabaseKeyStore } from './database-key-store'
|
||||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||||
import { AIProviderService } from './services/ai-provider-service'
|
import { AIProviderService } from './services/ai-provider-service'
|
||||||
|
import { imageInsightService } from './services/image-insight-service'
|
||||||
|
import type {
|
||||||
|
ImageAnalysisRequest,
|
||||||
|
ImageAnalysisResponse,
|
||||||
|
ImageCandidate,
|
||||||
|
ImageCandidateQuery,
|
||||||
|
ImageInsight
|
||||||
|
} from '../shared/image-insight'
|
||||||
import { KeyServiceMac } from './key-service-mac'
|
import { KeyServiceMac } from './key-service-mac'
|
||||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||||
import * as chat from './services/chat-service'
|
import * as chat from './services/chat-service'
|
||||||
@@ -51,15 +61,28 @@ import {
|
|||||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||||
import {
|
import {
|
||||||
|
flushBootstrapCacheWritesSync,
|
||||||
getBootstrapCache,
|
getBootstrapCache,
|
||||||
|
getCachedMessagePage,
|
||||||
getCachedMessages,
|
getCachedMessages,
|
||||||
mergeBootstrapAvatars,
|
mergeBootstrapAvatars,
|
||||||
mergeCachedContactAvatars,
|
mergeCachedContactAvatars,
|
||||||
saveBootstrapContacts,
|
saveBootstrapContacts,
|
||||||
saveBootstrapSelf,
|
saveBootstrapSelf,
|
||||||
|
saveCachedGroupSnapshot,
|
||||||
saveCachedMessages
|
saveCachedMessages
|
||||||
} from './services/bootstrap-cache'
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
|
import { appLogger } from './app-logger'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import { appUpdateService } from './services/app-update-service'
|
||||||
|
import { clearCache, getCacheSummary } from './services/cache-service'
|
||||||
|
import type { CacheClearScope } from './services/cache-service'
|
||||||
|
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||||
|
import { VideoAssetService } from './video-asset-service'
|
||||||
|
import { cancelExport, revealExport, runExport } from './export-service'
|
||||||
|
import type { ExportRequest } from '../shared/export'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
@@ -69,19 +92,99 @@ installSafeConsole()
|
|||||||
let voiceService: VoiceService | null = null
|
let voiceService: VoiceService | null = null
|
||||||
let imageDecryptService: ImageDecryptService | null = null
|
let imageDecryptService: ImageDecryptService | null = null
|
||||||
let stickerService: StickerService | null = null
|
let stickerService: StickerService | null = null
|
||||||
|
let videoAssetService: VideoAssetService | null = null
|
||||||
const databaseKeyStore = new DatabaseKeyStore()
|
const databaseKeyStore = new DatabaseKeyStore()
|
||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
|
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||||
|
let recallProtectionGeneration = 0
|
||||||
|
let recallJournalTimer: NodeJS.Timeout | null = null
|
||||||
|
let wcdbBootstrapPromise: Promise<unknown> | null = null
|
||||||
|
|
||||||
|
function configureRecallProtection(
|
||||||
|
wcdb4Client: Wcdb4Client,
|
||||||
|
accountRoot: string,
|
||||||
|
enabled: boolean,
|
||||||
|
installJournal = false
|
||||||
|
): void {
|
||||||
|
recallProtectionGeneration += 1
|
||||||
|
const generation = recallProtectionGeneration
|
||||||
|
recallArchiveMonitor?.stop()
|
||||||
|
recallArchiveMonitor = null
|
||||||
|
if (recallJournalTimer) {
|
||||||
|
clearTimeout(recallJournalTimer)
|
||||||
|
recallJournalTimer = null
|
||||||
|
}
|
||||||
|
configureRecallArchive(enabled ? accountRoot : '')
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const monitor = new RecallArchiveMonitor(
|
||||||
|
() =>
|
||||||
|
wcdb4Client.getSessions().map((session) => ({
|
||||||
|
md5: wcdb4Client.md5(session.username),
|
||||||
|
m_nsUsrName: session.username,
|
||||||
|
type: session.username.endsWith('@chatroom') ? 'group' : 'user',
|
||||||
|
activityKey: [
|
||||||
|
session.raw['last_timestamp'],
|
||||||
|
session.raw['sort_timestamp'],
|
||||||
|
session.raw['last_msg_locald_id'],
|
||||||
|
session.raw['last_msg_type'],
|
||||||
|
session.raw['summary']
|
||||||
|
].join(':')
|
||||||
|
})),
|
||||||
|
(sessionMd5) =>
|
||||||
|
chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, {
|
||||||
|
limit: 500
|
||||||
|
})
|
||||||
|
)
|
||||||
|
recallArchiveMonitor = monitor
|
||||||
|
// Session indexing is not required to open the UI. Defer it so large databases
|
||||||
|
// do not make db:init wait for every conversation to be enumerated.
|
||||||
|
setImmediate(() => {
|
||||||
|
if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) {
|
||||||
|
monitor.seedAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!installJournal) return
|
||||||
|
// Creating recall triggers scans every message table and runs synchronously in
|
||||||
|
// the main process. Only do this after the user explicitly enables protection;
|
||||||
|
// existing installations remain active without repeating the scan at startup.
|
||||||
|
recallJournalTimer = setTimeout(() => {
|
||||||
|
recallJournalTimer = null
|
||||||
|
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
|
||||||
|
const result = wcdb4Client.installRecallJournal(
|
||||||
|
wcdb4Client.getSessions().map((session) => session.username)
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
|
||||||
|
)
|
||||||
|
}, 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||||
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: 'wxe-media',
|
||||||
|
privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
app.setName(
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'WeFlow'
|
||||||
|
: process.env['WXE_USER_DATA']
|
||||||
|
? 'WechatExplorer Dev'
|
||||||
|
: 'WechatExplorer'
|
||||||
|
)
|
||||||
|
const isolatedUserData = process.env['WXE_USER_DATA']
|
||||||
|
if (isolatedUserData) app.setPath('userData', isolatedUserData)
|
||||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||||
null
|
null
|
||||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||||
@@ -96,8 +199,65 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> {
|
||||||
|
const { size } = await fsPromises.stat(filePath)
|
||||||
|
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg'
|
||||||
|
const commonHeaders = {
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Cache-Control': 'private, max-age=300'
|
||||||
|
}
|
||||||
|
const range = request.headers.get('range')
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
const body =
|
||||||
|
request.method === 'HEAD' ? null : Uint8Array.from(await fsPromises.readFile(filePath))
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { ...commonHeaders, 'Content-Length': String(size) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim())
|
||||||
|
if (!match) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const start = Number(match[1])
|
||||||
|
const requestedEnd = match[2] ? Number(match[2]) : size - 1
|
||||||
|
const end = Math.min(requestedEnd, size - 1)
|
||||||
|
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = end - start + 1
|
||||||
|
let body: Buffer | null = null
|
||||||
|
if (request.method !== 'HEAD') {
|
||||||
|
const handle = await fsPromises.open(filePath, 'r')
|
||||||
|
try {
|
||||||
|
body = Buffer.allocUnsafe(length)
|
||||||
|
await handle.read(body, 0, length, start)
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(body ? Uint8Array.from(body) : null, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
...commonHeaders,
|
||||||
|
'Content-Length': String(length),
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${size}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// 鍒涘缓娴忚鍣ㄧ獥鍙?
|
// 创建浏览器窗口
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 800,
|
height: 800,
|
||||||
@@ -119,8 +279,8 @@ function createWindow(): void {
|
|||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
|
// 基于 electron-vite CLI 的渲染器热更新
|
||||||
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
|
// 加载开发环境的远程 URL,或生产环境的本地 HTML 文件
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
@@ -128,41 +288,88 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
|
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
||||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
// 某些 API 只能在此事件发生后使用
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
protocol.handle('wxe-media', async (request) => {
|
||||||
|
const token = new URL(request.url).pathname.replace(/^\/+/, '')
|
||||||
|
const filePath = videoAssetService?.pathForToken(token)
|
||||||
|
if (!filePath) return new Response('Not found', { status: 404 })
|
||||||
|
try {
|
||||||
|
return await createLocalMediaResponse(request, filePath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Video] local media request failed:', error)
|
||||||
|
return new Response('Media unavailable', { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||||
|
appLogger.write({
|
||||||
|
level: 'info',
|
||||||
|
scope: 'lifecycle',
|
||||||
|
message: 'WechatExplorer 启动',
|
||||||
|
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
||||||
|
})
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'main-process',
|
||||||
|
message: error.message,
|
||||||
|
details: { stack: error.stack }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'main-process',
|
||||||
|
message: reason instanceof Error ? reason.message : 'Promise 未处理拒绝',
|
||||||
|
details: {
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
reason: reason instanceof Error ? undefined : String(reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
|
||||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
createWindow()
|
||||||
// reuses the already-initialized library and skips wcdb_init.
|
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||||
try {
|
console.log('[WCDB4] async bootstrap complete')
|
||||||
bootstrapWcdbNative()
|
})
|
||||||
console.log('[WCDB4] bootstrap complete at whenReady top')
|
|
||||||
} catch (bootstrapError) {
|
|
||||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
|
// 设置应用程序用户模型 ID
|
||||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||||
|
|
||||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
||||||
|
|
||||||
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
|
// 开发环境中默认使用 F12 打开或关闭 DevTools
|
||||||
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
|
// 生产环境中忽略 CommandOrControl + R
|
||||||
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
||||||
|
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
||||||
|
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
|
||||||
|
ipcMain.handle('app-update:getState', () => appUpdateService.getState())
|
||||||
|
ipcMain.handle('app-update:check', () => appUpdateService.check())
|
||||||
|
ipcMain.handle('app-update:download', () => appUpdateService.download())
|
||||||
|
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
||||||
|
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
||||||
|
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
||||||
|
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'all']
|
||||||
|
if (!allowedScopes.includes(scope)) return getCacheSummary()
|
||||||
|
imageDecryptService = null
|
||||||
|
return clearCache(scope)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:init', async (_, key: string) => {
|
ipcMain.handle('db:init', async (_, key: string) => {
|
||||||
if (dbInitInFlight) return dbInitInFlight
|
if (dbInitInFlight) return dbInitInFlight
|
||||||
|
|
||||||
dbInitInFlight = (async () => {
|
dbInitInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
|
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||||
const trimmedKey = String(key || '').trim()
|
const trimmedKey = String(key || '').trim()
|
||||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
@@ -177,17 +384,33 @@ app.whenReady().then(async () => {
|
|||||||
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
||||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
||||||
saveSettings({ ...settings, dbRoot: resolvedRoot })
|
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||||
|
saveSettings({
|
||||||
|
...settings,
|
||||||
|
dbRoot: resolvedRoot,
|
||||||
|
imageKeyRoot: resolvedRoot
|
||||||
|
})
|
||||||
}
|
}
|
||||||
chat.setChatDb(nextWechatDb)
|
chat.setChatDb(nextWechatDb)
|
||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||||
|
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||||
voiceService = new VoiceService(wcdb4Client)
|
voiceService = new VoiceService(wcdb4Client)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
|
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||||
|
wcdb4Client.invalidateSessionCache()
|
||||||
|
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setImmediate(() => {
|
||||||
|
const recentSession = wcdb4Client.getSessions()[0]
|
||||||
|
if (!recentSession?.username) return
|
||||||
|
void wcdb4Client
|
||||||
|
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
|
||||||
|
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
|
||||||
|
})
|
||||||
imageDecryptService = null
|
imageDecryptService = null
|
||||||
return { success: true, monitoring }
|
return { success: true, monitoring }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -258,7 +481,10 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
const self = chat.getSelfAccountInfo()
|
const self = chat.getSelfAccountInfo()
|
||||||
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
|
// 优先级:chat 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot
|
||||||
|
// 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。
|
||||||
|
const accountRoot =
|
||||||
|
chat.getCurrentAccountRoot() || self?.accountRoot || settings.imageKeyRoot || settings.dbRoot
|
||||||
const wxid = self?.wxid
|
const wxid = self?.wxid
|
||||||
const onStatus = (message: string): void => {
|
const onStatus = (message: string): void => {
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
|
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
|
||||||
@@ -325,11 +551,26 @@ app.whenReady().then(async () => {
|
|||||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:getStartupCache', () => {
|
||||||
|
const settings = loadSettings()
|
||||||
|
return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getCachedMessages',
|
'db:getCachedMessages',
|
||||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||||
if (!chat.isReady()) return []
|
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||||
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
|
return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'db:getCachedMessagePage',
|
||||||
|
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||||
|
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||||
|
return accountRoot
|
||||||
|
? getCachedMessagePage(accountRoot, userMd5, startTime, endTime)
|
||||||
|
: { hit: false, messages: [] }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -352,8 +593,14 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getMessages',
|
'db:getMessages',
|
||||||
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
async (
|
||||||
const messages = chat.listMessages(userMd5, startTime, endTime, options)
|
_,
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
) => {
|
||||||
|
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
if (chat.isReady()) {
|
if (chat.isReady()) {
|
||||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||||
}
|
}
|
||||||
@@ -361,7 +608,13 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
|
||||||
|
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
|
||||||
|
if (snapshot && chat.isReady()) {
|
||||||
|
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
|
|
||||||
@@ -404,6 +657,23 @@ app.whenReady().then(async () => {
|
|||||||
return exportGroupReport(request)
|
return exportGroupReport(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)
|
||||||
|
})
|
||||||
|
ipcMain.handle('export:cancel', (_, jobId: string) => {
|
||||||
|
cancelExport(jobId)
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
ipcMain.handle('export:reveal', async (_, path: string) => {
|
||||||
|
try {
|
||||||
|
await revealExport(path)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
}
|
||||||
|
})
|
||||||
ipcMain.handle('report:listGenerated', async () => {
|
ipcMain.handle('report:listGenerated', async () => {
|
||||||
return listGeneratedReports()
|
return listGeneratedReports()
|
||||||
})
|
})
|
||||||
@@ -446,7 +716,7 @@ app.whenReady().then(async () => {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
_sessionId?: string,
|
_sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => {
|
) => {
|
||||||
void _sessionId
|
void _sessionId
|
||||||
if (!imageDecryptService) {
|
if (!imageDecryptService) {
|
||||||
@@ -463,29 +733,139 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||||
const force = options?.force === true
|
const force = options?.force === true
|
||||||
|
const preferThumbnail = options?.preferThumbnail === true
|
||||||
|
const imageCacheKey = [
|
||||||
|
imageMd5 || '',
|
||||||
|
imageDatName || '',
|
||||||
|
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
|
||||||
|
].join('|')
|
||||||
|
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
|
||||||
|
if (cachedImage) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cachedImage.data,
|
||||||
|
isThumb: cachedImage.isThumbnail,
|
||||||
|
filePath: cachedImage.filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
let filePath = force
|
let filePath = force
|
||||||
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||||
: null
|
: null
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||||
allowThumbnail: true
|
allowThumbnail: true,
|
||||||
|
preferThumbnail
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = imageDecryptService.decryptImageToBase64(filePath)
|
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
|
||||||
if (!base64) {
|
if (!decrypted) {
|
||||||
return { success: false, error: '图片解密失败' }
|
return { success: false, error: '图片解密失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
data: base64,
|
data: decrypted.data,
|
||||||
isThumb: imageDecryptService.isThumbnailFile(filePath),
|
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||||
filePath
|
filePath: decrypted.filePath
|
||||||
}
|
}
|
||||||
|
imageDecryptService.cacheDecodedImage(imageCacheKey, {
|
||||||
|
data: result.data,
|
||||||
|
filePath: result.filePath,
|
||||||
|
isThumbnail: result.isThumb
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AI 图片理解基础设施(ImageInsightService)
|
||||||
|
// ============================================================
|
||||||
|
// 注入依赖(用闭包捕获当前 db:getImage 已经初始化过的 imageDecryptService)
|
||||||
|
// 同时把 imageDecryptService 暴露到 globalThis,供 group-report-service 渲染时按 imageHash 取图
|
||||||
|
;(globalThis as { __imageDecrypt?: typeof imageDecryptService }).__imageDecrypt =
|
||||||
|
imageDecryptService
|
||||||
|
imageInsightService.bind({
|
||||||
|
providerService: aiProviderService,
|
||||||
|
decryptService: {
|
||||||
|
findImageFile: (md5, datName, opts) =>
|
||||||
|
imageDecryptService?.findImageFile(md5, datName, opts) ?? null,
|
||||||
|
decryptImageToBase64: (filePath) =>
|
||||||
|
imageDecryptService?.decryptImageToBase64(filePath) ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 日报入口:取会话 Top N 热点图片 + 已缓存的 Insight */
|
||||||
|
ipcMain.handle(
|
||||||
|
'image:listCandidates',
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
query: ImageCandidateQuery
|
||||||
|
): Promise<{ success: boolean; candidates: ImageCandidate[]; error?: string }> => {
|
||||||
|
console.log('[IPC] image:listCandidates query=%j', query)
|
||||||
|
try {
|
||||||
|
const inputs = (query as ImageCandidateQuery & { inputs?: unknown[] }).inputs || []
|
||||||
|
console.log('[IPC] image:listCandidates received %d inputs', inputs.length)
|
||||||
|
const candidates = await imageInsightService.listTopHotImages(query, inputs as never)
|
||||||
|
console.log('[IPC] image:listCandidates returned %d candidates', candidates.length)
|
||||||
|
return { success: true, candidates }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IPC] image:listCandidates failed:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
candidates: [],
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 单图分析:缓存命中即返回,未命中调 AI;失败不抛 */
|
||||||
|
ipcMain.handle(
|
||||||
|
'image:analyze',
|
||||||
|
async (_, request: ImageAnalysisRequest): Promise<ImageAnalysisResponse> => {
|
||||||
|
console.log('[IPC] image:analyze hash=%s messageId=%s', request.imageHash, request.messageId)
|
||||||
|
// 校验 provider 是否支持 vision
|
||||||
|
const runtime = aiProviderService.getRuntimeConfig()
|
||||||
|
if (!runtime.configured) {
|
||||||
|
return { success: false, error: '尚未配置 AI Provider' }
|
||||||
|
}
|
||||||
|
const list = aiProviderService.list()
|
||||||
|
const provider = list.providers.find((p) => p.id === runtime.providerId)
|
||||||
|
const model = provider?.models.find((m) => m.id === runtime.model)
|
||||||
|
if (!provider || !model) {
|
||||||
|
return { success: false, error: '当前 AI 模型不存在' }
|
||||||
|
}
|
||||||
|
// Capability metadata is stored per machine. A model verified on macOS
|
||||||
|
// may still be unmarked on Windows, so do not reject before making the
|
||||||
|
// real multimodal request. The provider response remains authoritative.
|
||||||
|
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
|
||||||
|
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
|
||||||
|
return imageInsightService.analyze(request)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 单图查询缓存 */
|
||||||
|
ipcMain.handle(
|
||||||
|
'image:getInsight',
|
||||||
|
async (_, imageHash: string): Promise<{ success: boolean; insight?: ImageInsight }> => {
|
||||||
|
const insight = imageInsightService.getInsight(imageHash)
|
||||||
|
return { success: true, insight: insight || undefined }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 列出某会话所有已分析的 insights */
|
||||||
|
ipcMain.handle(
|
||||||
|
'image:listInsights',
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
sessionId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<{ success: boolean; insights: ImageInsight[] }> => {
|
||||||
|
return { success: true, insights: imageInsightService.listBySession(sessionId, limit) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -496,6 +876,15 @@ app.whenReady().then(async () => {
|
|||||||
return stickerService.resolveSticker(cdnUrl, md5)
|
return stickerService.resolveSticker(cdnUrl, md5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
||||||
|
if (!videoAssetService) {
|
||||||
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||||
|
videoAssetService = new VideoAssetService(client)
|
||||||
|
}
|
||||||
|
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
|
||||||
|
})
|
||||||
|
|
||||||
// -------- Settings & API service --------
|
// -------- Settings & API service --------
|
||||||
|
|
||||||
ipcMain.handle('settings:get', () => ({
|
ipcMain.handle('settings:get', () => ({
|
||||||
@@ -510,13 +899,22 @@ app.whenReady().then(async () => {
|
|||||||
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
|
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
|
||||||
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
|
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
|
||||||
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
|
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
|
||||||
|
const nextSettings = saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||||
if (includesImageKey) {
|
if (includesImageKey) {
|
||||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
|
||||||
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
|
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
|
||||||
else imageKeyConfigService.clear()
|
else imageKeyConfigService.clear()
|
||||||
imageDecryptService = null
|
imageDecryptService = null
|
||||||
} else {
|
}
|
||||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
if ('recallProtectionEnabled' in patch && chat.isReady()) {
|
||||||
|
const currentDb = chat.getChatDb()
|
||||||
|
if (currentDb) {
|
||||||
|
configureRecallProtection(
|
||||||
|
currentDb.getWcdb4Client(),
|
||||||
|
chat.getCurrentAccountRoot(),
|
||||||
|
nextSettings.recallProtectionEnabled,
|
||||||
|
nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
settings: imageKeyConfigService.getLegacySettingsView(),
|
settings: imageKeyConfigService.getLegacySettingsView(),
|
||||||
@@ -538,6 +936,11 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
||||||
const ok = chat.reopenWithRoot(accountRoot)
|
const ok = chat.reopenWithRoot(accountRoot)
|
||||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||||
|
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
|
||||||
|
const settings = loadSettings()
|
||||||
|
if (accountRoot && accountRoot !== settings.imageKeyRoot) {
|
||||||
|
saveSettings({ ...settings, imageKeyRoot: accountRoot })
|
||||||
|
}
|
||||||
const info = chat.getSelfAccountInfo()
|
const info = chat.getSelfAccountInfo()
|
||||||
return { success: true, info }
|
return { success: true, info }
|
||||||
})
|
})
|
||||||
@@ -558,9 +961,10 @@ app.whenReady().then(async () => {
|
|||||||
return error ? { success: false, error } : { success: true }
|
return error ? { success: false, error } : { success: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:disconnect', () => {
|
ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => {
|
||||||
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
|
// 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。
|
||||||
chat.setChatDb(null)
|
// 即使当前未就绪,也应让用户正常返回登录页。
|
||||||
|
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -602,30 +1006,49 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('agent-hub:getStatus', () => agentHubService.getStatus())
|
||||||
|
ipcMain.handle('agent-hub:getLogs', () => agentHubService.getLogs())
|
||||||
|
ipcMain.handle('agent-hub:clearLogs', () => agentHubService.clearLogs())
|
||||||
|
ipcMain.handle('agent-hub:startLogin', () => agentHubService.startLogin())
|
||||||
|
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
|
||||||
|
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
|
||||||
|
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
||||||
|
ipcMain.handle('agent-hub:selectTestImage', 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: ['*'] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||||
|
})
|
||||||
|
|
||||||
createWindow()
|
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||||
|
|
||||||
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
|
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
if (settings.apiEnabled) {
|
if (settings.apiEnabled) {
|
||||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await agentHubService.start(settings)
|
||||||
|
|
||||||
if (TRAY_MODE) {
|
if (TRAY_MODE) {
|
||||||
app.dock?.hide()
|
app.dock?.hide()
|
||||||
setupTray()
|
setupTray()
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
|
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
||||||
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
|
// 通常会在应用程序中重新创建一个窗口。
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
|
// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上,
|
||||||
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
|
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||||
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
|
// 明确使用 Cmd + Q 退出。
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (TRAY_MODE) return
|
if (TRAY_MODE) return
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
@@ -634,6 +1057,8 @@ app.on('window-all-closed', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
|
agentHubService.stop()
|
||||||
|
flushBootstrapCacheWritesSync()
|
||||||
chat.setChatDb(null)
|
chat.setChatDb(null)
|
||||||
await apiServer.stop().catch(() => undefined)
|
await apiServer.stop().catch(() => undefined)
|
||||||
if (tray) {
|
if (tray) {
|
||||||
|
|||||||
@@ -889,7 +889,8 @@ export class KeyService {
|
|||||||
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||||
|
|
||||||
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
|
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
|
||||||
|
const marker = normalized.match(/[\\/]xwechat_files/i)
|
||||||
if (marker) {
|
if (marker) {
|
||||||
const root = normalized.slice(0, marker.index! + marker[0].length)
|
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||||
try {
|
try {
|
||||||
@@ -934,15 +935,49 @@ export class KeyService {
|
|||||||
onProgress?.('正在查找模板文件...')
|
onProgress?.('正在查找模板文件...')
|
||||||
let result = await this._findTemplateData(userDir, 32)
|
let result = await this._findTemplateData(userDir, 32)
|
||||||
let { ciphertext, xorKey } = result
|
let { ciphertext, xorKey } = result
|
||||||
|
const firstDiag = (this as { _imageTemplateDiag?: {
|
||||||
|
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||||
|
} })._imageTemplateDiag
|
||||||
|
|
||||||
// 如果找不到密钥,尝试扫描更多文件
|
// 如果找不到密钥,尝试扫描更多文件
|
||||||
if (ciphertext && xorKey === null) {
|
if (ciphertext && xorKey === null) {
|
||||||
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||||
result = await this._findTemplateData(userDir, 100)
|
result = await this._findTemplateData(userDir, 100)
|
||||||
xorKey = result.xorKey
|
xorKey = result.xorKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
if (!ciphertext) {
|
||||||
|
// 用诊断信息给具体提示
|
||||||
|
const diag = (this as { _imageTemplateDiag?: {
|
||||||
|
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||||
|
} })._imageTemplateDiag || firstDiag
|
||||||
|
if (!diag || diag.totalTFiles === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'在账号目录下未找到任何 _t.dat 图片文件。\n' +
|
||||||
|
`扫描路径:${diag?.userDir || userDir || '(空)'}\n` +
|
||||||
|
'原因:微信没在本地生成缩略图。\n' +
|
||||||
|
'请让用户在微信里打开任意聊天的图片大图(等"原图"按钮可点击),然后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (diag.v2Count === 0 && diag.nonV2Count > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`找到 ${diag.totalTFiles} 个 _t.dat,但都不是 V2 头(可能图片尚未解密到本地,或微信版本不同)。\n` +
|
||||||
|
`扫描路径:${diag.userDir}\n` +
|
||||||
|
'请让用户在微信里打开 2-3 张不同的图片大图,等"原图"按钮可点击后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`找到 ${diag.totalTFiles} 个 _t.dat,其中 ${diag.v2Count} 个是 V2 头,但没有长度 ≥ 0x1F 的有效模板。\n` +
|
||||||
|
`扫描路径:${diag.userDir}\n` +
|
||||||
|
'请在微信中查看更多图片后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||||
|
|
||||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
@@ -1005,6 +1040,8 @@ export class KeyService {
|
|||||||
|
|
||||||
let ciphertext: Buffer | null = null
|
let ciphertext: Buffer | null = null
|
||||||
const tailCounts: Record<string, number> = {}
|
const tailCounts: Record<string, number> = {}
|
||||||
|
let v2Count = 0
|
||||||
|
let nonV2Count = 0
|
||||||
|
|
||||||
for (const f of files.slice(0, 32)) {
|
for (const f of files.slice(0, 32)) {
|
||||||
try {
|
try {
|
||||||
@@ -1013,8 +1050,11 @@ export class KeyService {
|
|||||||
|
|
||||||
// 统计末尾两字节用于 XOR 密钥
|
// 统计末尾两字节用于 XOR 密钥
|
||||||
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||||
|
v2Count++
|
||||||
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||||
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||||
|
} else {
|
||||||
|
nonV2Count++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取密文(取第一个有效的)
|
// 提取密文(取第一个有效的)
|
||||||
@@ -1031,6 +1071,15 @@ export class KeyService {
|
|||||||
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
|
||||||
|
const diag = {
|
||||||
|
userDir,
|
||||||
|
totalTFiles: files.length,
|
||||||
|
v2Count,
|
||||||
|
nonV2Count
|
||||||
|
}
|
||||||
|
;(this as { _imageTemplateDiag?: unknown })._imageTemplateDiag = diag
|
||||||
|
|
||||||
return { ciphertext, xorKey }
|
return { ciphertext, xorKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
}
|
}
|
||||||
|
type MiniProgramContent = {
|
||||||
|
type: 'miniProgram'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
appName?: string
|
||||||
|
iconUrl?: string
|
||||||
|
thumbMd5?: string
|
||||||
|
thumbDatName?: string
|
||||||
|
thumbDataUrl?: string
|
||||||
|
}
|
||||||
|
type RedPacketContent = {
|
||||||
|
type: 'redPacket'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
type ImageContent = {
|
type ImageContent = {
|
||||||
type: 'image'
|
type: 'image'
|
||||||
@@ -24,6 +40,15 @@ type ImageContent = {
|
|||||||
aeskey?: string
|
aeskey?: string
|
||||||
encrypVer?: number
|
encrypVer?: number
|
||||||
}
|
}
|
||||||
|
type VideoContent = {
|
||||||
|
type: 'video'
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
type StickerContent = {
|
type StickerContent = {
|
||||||
type: 'sticker'
|
type: 'sticker'
|
||||||
md5?: string
|
md5?: string
|
||||||
@@ -40,8 +65,23 @@ type QuoteContent = {
|
|||||||
quotedContent?: string
|
quotedContent?: string
|
||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
quotedType?: string
|
quotedType?: string
|
||||||
|
quotedImageMd5?: string
|
||||||
|
quotedImageDatName?: string
|
||||||
|
}
|
||||||
|
type SystemContent = {
|
||||||
|
type: 'system'
|
||||||
|
content: string
|
||||||
|
raw?: string
|
||||||
|
pat?: boolean
|
||||||
|
recall?: {
|
||||||
|
targetId?: string
|
||||||
|
targetIds?: string[]
|
||||||
|
replacement: string
|
||||||
|
actor?: string
|
||||||
|
sessionId?: string
|
||||||
|
recallTime?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
|
||||||
type UnknownContent = { type: 'unknown'; raw: string }
|
type UnknownContent = { type: 'unknown'; raw: string }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
@@ -50,8 +90,11 @@ export type ParsedContent =
|
|||||||
| LocationContent
|
| LocationContent
|
||||||
| CardContent
|
| CardContent
|
||||||
| ShareContent
|
| ShareContent
|
||||||
|
| MiniProgramContent
|
||||||
|
| RedPacketContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
| ImageContent
|
| ImageContent
|
||||||
|
| VideoContent
|
||||||
| StickerContent
|
| StickerContent
|
||||||
| QuoteContent
|
| QuoteContent
|
||||||
| SystemContent
|
| SystemContent
|
||||||
@@ -69,6 +112,8 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
return parseImageMessage(normalized)
|
return parseImageMessage(normalized)
|
||||||
case 42:
|
case 42:
|
||||||
return parseCardMessage(normalized)
|
return parseCardMessage(normalized)
|
||||||
|
case 43:
|
||||||
|
return parseVideoMessage(normalized)
|
||||||
case 47:
|
case 47:
|
||||||
return parseStickerMessage(normalized)
|
return parseStickerMessage(normalized)
|
||||||
case 48:
|
case 48:
|
||||||
@@ -85,9 +130,31 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseVideoMessage(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||||
|
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||||
|
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
||||||
|
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
|
||||||
|
|
||||||
|
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||||
|
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||||
|
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
|
||||||
|
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
function parseSystemMessage(content: string): ParsedContent {
|
function parseSystemMessage(content: string): ParsedContent {
|
||||||
const stripped = stripChatroomPrefix(content)
|
const stripped = stripChatroomPrefix(content)
|
||||||
const decoded = decodeXmlEntities(stripped)
|
const decoded = decodeXmlEntities(stripped)
|
||||||
|
const recall = extractRecallMessage(decoded)
|
||||||
|
if (recall) {
|
||||||
|
return {
|
||||||
|
type: 'system',
|
||||||
|
content: recall.replacement,
|
||||||
|
raw: content,
|
||||||
|
recall
|
||||||
|
}
|
||||||
|
}
|
||||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||||
if (delChatroomMemberText) {
|
if (delChatroomMemberText) {
|
||||||
return {
|
return {
|
||||||
@@ -113,6 +180,50 @@ function parseSystemMessage(content: string): ParsedContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractRecallMessage(xml: string):
|
||||||
|
| {
|
||||||
|
targetId?: string
|
||||||
|
targetIds?: string[]
|
||||||
|
replacement: string
|
||||||
|
actor?: string
|
||||||
|
sessionId?: string
|
||||||
|
recallTime?: number
|
||||||
|
}
|
||||||
|
| undefined {
|
||||||
|
if (!/<revokemsg\b/i.test(xml)) return undefined
|
||||||
|
|
||||||
|
const replacement = normalizeSystemText(
|
||||||
|
extractXmlValue(xml, 'replacemsg') ||
|
||||||
|
extractXmlNodeText(xml, 'replacemsg') ||
|
||||||
|
extractXmlValue(xml, 'content') ||
|
||||||
|
extractXmlNodeText(xml, 'content')
|
||||||
|
)
|
||||||
|
if (!replacement) return undefined
|
||||||
|
|
||||||
|
const actorMatch =
|
||||||
|
/^["“](.+?)["”]\s*撤回了一条消息/.exec(replacement) ||
|
||||||
|
/^(.+?)\s*撤回了一条消息/.exec(replacement)
|
||||||
|
|
||||||
|
const targetIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
extractXmlValue(xml, 'newmsgid'),
|
||||||
|
extractXmlValue(xml, 'msgid'),
|
||||||
|
extractXmlValue(xml, 'clientmsgid')
|
||||||
|
].filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetId: targetIds[0] || undefined,
|
||||||
|
targetIds,
|
||||||
|
replacement,
|
||||||
|
actor: actorMatch?.[1]?.trim() || undefined,
|
||||||
|
sessionId: extractXmlValue(xml, 'session') || undefined,
|
||||||
|
recallTime: Number(extractXmlValue(xml, 'revoketime')) || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseImageMessage(content: string): ParsedContent {
|
function parseImageMessage(content: string): ParsedContent {
|
||||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||||
@@ -301,6 +412,10 @@ function parseLocationMessage(content: string): ParsedContent {
|
|||||||
|
|
||||||
function parseShareMessage(content: string): ParsedContent {
|
function parseShareMessage(content: string): ParsedContent {
|
||||||
const appMsgType = extractAppMsgType(content)
|
const appMsgType = extractAppMsgType(content)
|
||||||
|
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||||
|
const sticker = parseStickerMessage(content)
|
||||||
|
if (sticker.type === 'sticker') return sticker
|
||||||
|
}
|
||||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||||
const quote = parseQuoteMessage(content)
|
const quote = parseQuoteMessage(content)
|
||||||
const title = extractXmlValue(content, 'title') || undefined
|
const title = extractXmlValue(content, 'title') || undefined
|
||||||
@@ -310,7 +425,34 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
content: title,
|
content: title,
|
||||||
quotedContent: quote.content || '[引用消息]',
|
quotedContent: quote.content || '[引用消息]',
|
||||||
quotedSender: quote.sender,
|
quotedSender: quote.sender,
|
||||||
quotedType: quote.type
|
quotedType: quote.type,
|
||||||
|
quotedImageMd5: quote.imageMd5,
|
||||||
|
quotedImageDatName: quote.imageDatName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appMsgType === '33' || appMsgType === '36') {
|
||||||
|
return {
|
||||||
|
type: 'miniProgram',
|
||||||
|
title: extractXmlValue(content, 'title') || '小程序',
|
||||||
|
description: extractXmlValue(content, 'des') || undefined,
|
||||||
|
appName:
|
||||||
|
extractXmlValue(content, 'sourcedisplayname') ||
|
||||||
|
extractXmlValue(content, 'appname') ||
|
||||||
|
'小程序',
|
||||||
|
iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined,
|
||||||
|
thumbMd5: normalizeMd5(
|
||||||
|
extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appMsgType === '2001') {
|
||||||
|
return {
|
||||||
|
type: 'redPacket',
|
||||||
|
title: extractXmlValue(content, 'title') || '微信红包',
|
||||||
|
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
||||||
|
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +469,13 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
return { type: 'share', title, des, url, appname, typeVal }
|
return { type: 'share', title, des, url, appname, typeVal }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
function parseQuoteMessage(content: string): {
|
||||||
|
content?: string
|
||||||
|
sender?: string
|
||||||
|
type?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
} {
|
||||||
const referMsgStart = content.indexOf('<refermsg>')
|
const referMsgStart = content.indexOf('<refermsg>')
|
||||||
const referMsgEnd = content.indexOf('</refermsg>')
|
const referMsgEnd = content.indexOf('</refermsg>')
|
||||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||||
@@ -343,8 +491,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
|
|||||||
switch (referType) {
|
switch (referType) {
|
||||||
case '1':
|
case '1':
|
||||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||||
case '3':
|
case '3': {
|
||||||
return { sender, content: '[图片]', type: referType }
|
const image = parseImageMessage(referContent)
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
content: '[图片]',
|
||||||
|
type: referType,
|
||||||
|
imageMd5: image.type === 'image' ? image.md5 : undefined,
|
||||||
|
imageDatName: image.type === 'image' ? image.datName : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
case '34':
|
case '34':
|
||||||
return { sender, content: '[语音]', type: referType }
|
return { sender, content: '[语音]', type: referType }
|
||||||
case '43':
|
case '43':
|
||||||
@@ -372,6 +528,10 @@ function extractAppMsgType(content: string): string {
|
|||||||
const inner = appmsgMatch[1]
|
const inner = appmsgMatch[1]
|
||||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
.replace(/<weappinfo[\s\S]*?<\/weappinfo>/gi, '')
|
||||||
|
.replace(/<appattach[\s\S]*?<\/appattach>/gi, '')
|
||||||
|
.replace(/<wcpayinfo[\s\S]*?<\/wcpayinfo>/gi, '')
|
||||||
|
.replace(/<findernamecard[\s\S]*?<\/findernamecard>/gi, '')
|
||||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||||
return typeMatch?.[1]?.trim() || ''
|
return typeMatch?.[1]?.trim() || ''
|
||||||
}
|
}
|
||||||
@@ -433,7 +593,10 @@ function extractXmlValue(xml: string, tagName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
const pattern = new RegExp(
|
||||||
|
`<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`,
|
||||||
|
'i'
|
||||||
|
)
|
||||||
const match = xml.match(pattern)
|
const match = xml.match(pattern)
|
||||||
return match ? match[1].trim() : ''
|
return match ? match[1].trim() : ''
|
||||||
}
|
}
|
||||||
@@ -550,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
|||||||
return hexMatch?.[1]?.toLowerCase()
|
return hexMatch?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseImageBufferDataUrlFromRow(
|
||||||
|
row: Record<string, unknown>
|
||||||
|
): string | undefined {
|
||||||
|
const raw = pickRowString(row, [
|
||||||
|
'ImgBuf',
|
||||||
|
'imgBuf',
|
||||||
|
'img_buf',
|
||||||
|
'imageBuffer',
|
||||||
|
'image_buffer',
|
||||||
|
'thumbBuffer',
|
||||||
|
'thumb_buffer',
|
||||||
|
'WCDB_CT_img_buf',
|
||||||
|
'WCDB_CT_ImgBuf'
|
||||||
|
])
|
||||||
|
const buffer = decodeInlineImageBuffer(raw)
|
||||||
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
|
const mime = detectImageMime(buffer)
|
||||||
|
return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeInlineImageBuffer(raw: unknown): Buffer | null {
|
||||||
|
if (!raw) return null
|
||||||
|
if (Buffer.isBuffer(raw)) return raw
|
||||||
|
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||||
|
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
const record = raw as { buffer?: unknown; data?: unknown }
|
||||||
|
return decodeInlineImageBuffer(record.buffer ?? record.data)
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') return null
|
||||||
|
const value = raw.trim()
|
||||||
|
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value)
|
||||||
|
const encoded = dataUrl?.[1] || value
|
||||||
|
if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null
|
||||||
|
try {
|
||||||
|
return Buffer.from(encoded, 'base64')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImageMime(buffer: Buffer): string | undefined {
|
||||||
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 8 &&
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer.subarray(1, 4).toString('ascii') === 'PNG'
|
||||||
|
) {
|
||||||
|
return 'image/png'
|
||||||
|
}
|
||||||
|
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) {
|
||||||
|
return 'image/gif'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export function isPackagedRuntime(): boolean {
|
||||||
|
if (app.isPackaged) return true
|
||||||
|
|
||||||
|
return (
|
||||||
|
existsSync(join(process.resourcesPath, 'app.asar')) &&
|
||||||
|
existsSync(join(process.resourcesPath, 'app-update.yml'))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Contact, Message } from '../../shared/types'
|
||||||
|
import { exportGroupReport } from '../group-report-service'
|
||||||
|
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
|
||||||
|
import { AIProviderService } from './ai-provider-service'
|
||||||
|
import {
|
||||||
|
buildGroupReportInput,
|
||||||
|
getSummaryDateRange,
|
||||||
|
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
|
||||||
|
GROUP_REPORT_SYSTEM_PROMPT,
|
||||||
|
isInternalName,
|
||||||
|
parseGroupDailyReport,
|
||||||
|
type SummaryDateRange
|
||||||
|
} from '../../renderer/src/utils/group-report'
|
||||||
|
|
||||||
|
const aiProvider = new AIProviderService()
|
||||||
|
|
||||||
|
export interface AgentGroupReportRequest {
|
||||||
|
group: string
|
||||||
|
range?: SummaryDateRange
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentGroupReportResult {
|
||||||
|
success: boolean
|
||||||
|
groupName?: string
|
||||||
|
pngPath?: string
|
||||||
|
messageCount?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAgentGroupReport(
|
||||||
|
request: AgentGroupReportRequest
|
||||||
|
): Promise<AgentGroupReportResult> {
|
||||||
|
const query = String(request.group || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/群聊?$/, '')
|
||||||
|
.trim()
|
||||||
|
if (!query) return { success: false, error: '缺少群聊名称' }
|
||||||
|
const contact = resolveMd5(query)
|
||||||
|
if (!contact) return { success: false, error: `没有找到群聊“${query}”` }
|
||||||
|
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
|
||||||
|
return { success: false, error: `“${query}”不是群聊` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
||||||
|
const { startTime, endTime } = getSummaryDateRange(range)
|
||||||
|
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
||||||
|
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
||||||
|
|
||||||
|
const snapshot = getGroupSnapshot(contact.md5)
|
||||||
|
if (snapshot) {
|
||||||
|
const members = new Map(
|
||||||
|
snapshot.members.map((member) => [
|
||||||
|
member.wxid,
|
||||||
|
{ name: member.nickname, avatar: member.avatar }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
messages = messages.map((message) => {
|
||||||
|
if (!isInternalName(message.name)) return message
|
||||||
|
const member = members.get(String(message.senderId || message.name || ''))
|
||||||
|
return member?.name
|
||||||
|
? { ...message, name: member.name, img: message.img || member.avatar }
|
||||||
|
: message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
||||||
|
const ai = await aiProvider.chat([
|
||||||
|
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: input.prompt }
|
||||||
|
])
|
||||||
|
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
||||||
|
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
||||||
|
parseGroupDailyReport(
|
||||||
|
raw,
|
||||||
|
input.topSpeakers,
|
||||||
|
input.activeTimeline,
|
||||||
|
input.voiceLeaderboard,
|
||||||
|
input.metadata,
|
||||||
|
input.media
|
||||||
|
)
|
||||||
|
let report: ReturnType<typeof parseGroupDailyReport>
|
||||||
|
try {
|
||||||
|
report = parseReport(ai.data)
|
||||||
|
} catch (parseError) {
|
||||||
|
const repaired = await aiProvider.chat([
|
||||||
|
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: ai.data }
|
||||||
|
])
|
||||||
|
if (!repaired.success || !repaired.data) {
|
||||||
|
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
report = parseReport(repaired.data)
|
||||||
|
} catch (repairError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: repairError instanceof Error ? repairError.message : String(repairError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const exported = await exportGroupReport({ report, metadata: input.metadata })
|
||||||
|
if (!exported.success || !exported.pngPath) {
|
||||||
|
return { success: false, error: exported.error || '总结图片生成失败' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
groupName: input.metadata.groupName,
|
||||||
|
pngPath: exported.pngPath,
|
||||||
|
messageCount: messages.length
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ export class AIProviderService {
|
|||||||
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||||
|
|
||||||
list(): AIProviderListResult {
|
list(): AIProviderListResult {
|
||||||
this.ensureEnvironmentMigration()
|
|
||||||
try {
|
try {
|
||||||
const data = this.readMetadata()
|
const data = this.readMetadata()
|
||||||
return {
|
return {
|
||||||
@@ -69,7 +68,8 @@ export class AIProviderService {
|
|||||||
configured: Boolean(
|
configured: Boolean(
|
||||||
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
|
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
|
||||||
),
|
),
|
||||||
status: provider?.status || 'untested'
|
status: provider?.status || 'untested',
|
||||||
|
timeoutMs: provider?.advanced.timeoutMs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +169,40 @@ export class AIProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多模态图片理解。
|
||||||
|
* 输入:text + image parts 的 messages,返回 AI 文本响应。
|
||||||
|
* 与 testVision 区别:不校验 prompt,不写入 capability marker(供 ImageInsightService 复用)。
|
||||||
|
*/
|
||||||
|
async analyzeImage(
|
||||||
|
messages: Array<{
|
||||||
|
role: string
|
||||||
|
content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
|
||||||
|
}>,
|
||||||
|
options?: AIChatRequestOptions
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: string
|
||||||
|
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const imagePart = messages
|
||||||
|
.flatMap((message) => (typeof message.content === 'string' ? [] : message.content))
|
||||||
|
.find((part) => part.type === 'image')
|
||||||
|
if (!imagePart || imagePart.type !== 'image') throw new Error('图片识别请求缺少图片数据')
|
||||||
|
const imageError = validateVisionImage(imagePart.dataUrl)
|
||||||
|
if (imageError) throw new Error(imageError)
|
||||||
|
const result = await this.request(messages as AIMessage[], options)
|
||||||
|
if (options?.providerId && options.modelId) {
|
||||||
|
this.markCapabilities(options.providerId, options.modelId, { vision: true, ocr: true })
|
||||||
|
}
|
||||||
|
return { success: true, ...result }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: safeAIError(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
|
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
const imageError = validateVisionImage(request.imageDataUrl)
|
const imageError = validateVisionImage(request.imageDataUrl)
|
||||||
@@ -215,7 +249,13 @@ export class AIProviderService {
|
|||||||
}> {
|
}> {
|
||||||
if (options?.apiKey) return this.requestLegacy(messages, options)
|
if (options?.apiKey) return this.requestLegacy(messages, options)
|
||||||
const resolved = this.resolveProvider(options)
|
const resolved = this.resolveProvider(options)
|
||||||
return requestProvider(resolved.provider, resolved.key, resolved.model, messages, testing)
|
const provider = options?.timeoutMs
|
||||||
|
? {
|
||||||
|
...resolved.provider,
|
||||||
|
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
|
||||||
|
}
|
||||||
|
: resolved.provider
|
||||||
|
return requestProvider(provider, resolved.key, resolved.model, messages, testing)
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
||||||
@@ -263,24 +303,38 @@ export class AIProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private markVisionCapability(providerId: string, modelId: string): void {
|
private markVisionCapability(providerId: string, modelId: string): void {
|
||||||
|
this.markCapabilities(providerId, modelId, { vision: true, ocr: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记模型已验证的 capabilities(已存在则跳过)。
|
||||||
|
* OCR 跟随 vision:几乎所有 vision 模型都能 OCR,标记 vision 时同步标记 ocr。
|
||||||
|
*/
|
||||||
|
private markCapabilities(
|
||||||
|
providerId: string,
|
||||||
|
modelId: string,
|
||||||
|
caps: { vision?: boolean; ocr?: boolean }
|
||||||
|
): void {
|
||||||
const data = this.readMetadata()
|
const data = this.readMetadata()
|
||||||
const provider = data.providers.find((item) => item.id === providerId)
|
const provider = data.providers.find((item) => item.id === providerId)
|
||||||
const model = provider?.models.find((item) => item.id === modelId)
|
const model = provider?.models.find((item) => item.id === modelId)
|
||||||
if (!provider || !model || model.capabilities.vision) return
|
if (!provider || !model) return
|
||||||
model.capabilities.vision = true
|
// 老配置可能没有 ocr 字段,补默认 false
|
||||||
this.writeMetadata(data)
|
if (typeof model.capabilities.ocr !== 'boolean') model.capabilities.ocr = false
|
||||||
}
|
let changed = false
|
||||||
|
if (caps.vision === true && !model.capabilities.vision) {
|
||||||
private ensureEnvironmentMigration(): void {
|
model.capabilities.vision = true
|
||||||
const data = this.readMetadata()
|
// vision 开启默认带 ocr(派生能力)
|
||||||
if (data.providers.length) return
|
if (!model.capabilities.ocr) {
|
||||||
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim()
|
model.capabilities.ocr = true
|
||||||
if (!apiKey) return
|
}
|
||||||
this.migrateLegacy({
|
changed = true
|
||||||
apiKey,
|
}
|
||||||
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
|
if (caps.ocr === true && !model.capabilities.ocr) {
|
||||||
model: String(import.meta.env.VITE_AI_MODEL || '')
|
model.capabilities.ocr = true
|
||||||
})
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) this.writeMetadata(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private toSummary(
|
private toSummary(
|
||||||
@@ -300,6 +354,14 @@ export class AIProviderService {
|
|||||||
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
|
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
|
||||||
if (data.version !== 1 || !Array.isArray(data.providers))
|
if (data.version !== 1 || !Array.isArray(data.providers))
|
||||||
throw new Error('invalid provider metadata')
|
throw new Error('invalid provider metadata')
|
||||||
|
// 老配置兼容:补 capabilities.ocr 默认值(vision 派生 OCR)
|
||||||
|
for (const provider of data.providers) {
|
||||||
|
for (const model of provider.models) {
|
||||||
|
if (typeof model.capabilities.ocr !== 'boolean') {
|
||||||
|
model.capabilities.ocr = model.capabilities.vision === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +387,7 @@ function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
|
|||||||
{
|
{
|
||||||
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
|
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
|
||||||
id: modelId,
|
id: modelId,
|
||||||
capabilities: { chat: true, vision: false, longContext: true }
|
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
defaultModel: modelId,
|
defaultModel: modelId,
|
||||||
@@ -454,7 +516,7 @@ async function requestOpenAICompatible(
|
|||||||
},
|
},
|
||||||
provider.advanced.timeoutMs
|
provider.advanced.timeoutMs
|
||||||
)
|
)
|
||||||
const payload = (await response.json()) as OpenAIResponsePayload
|
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||||
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
||||||
return {
|
return {
|
||||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||||
@@ -508,7 +570,7 @@ async function requestAnthropic(
|
|||||||
},
|
},
|
||||||
provider.advanced.timeoutMs
|
provider.advanced.timeoutMs
|
||||||
)
|
)
|
||||||
const payload = (await response.json()) as AnthropicResponsePayload
|
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
||||||
return {
|
return {
|
||||||
@@ -543,6 +605,20 @@ async function fetchWithTimeout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||||
|
const body = await response.text()
|
||||||
|
try {
|
||||||
|
return JSON.parse(body) as T
|
||||||
|
} catch {
|
||||||
|
const looksLikeHtml = /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)
|
||||||
|
const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
|
||||||
|
if (looksLikeHtml) {
|
||||||
|
throw new Error(`模型服务返回了网页而不是 JSON(HTTP ${status}),请稍后重试或检查中转服务`)
|
||||||
|
}
|
||||||
|
throw new Error(`模型服务返回格式异常(HTTP ${status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function safeAIError(error: unknown): string {
|
function safeAIError(error: unknown): string {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
|
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import { autoUpdater, type ProgressInfo } from 'electron-updater'
|
||||||
|
import type { AppUpdateCheckResult, AppUpdateState } from '../../shared/app-update'
|
||||||
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
|
||||||
|
export class AppUpdateService {
|
||||||
|
private state: AppUpdateState = {
|
||||||
|
status: 'idle',
|
||||||
|
currentVersion: app.getVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
autoUpdater.autoDownload = false
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' }))
|
||||||
|
autoUpdater.on('update-available', (info) =>
|
||||||
|
this.setState({ status: 'available', version: info.version, message: '发现新版本' })
|
||||||
|
)
|
||||||
|
autoUpdater.on('update-not-available', () =>
|
||||||
|
this.setState({ status: 'not-available', message: '当前已是最新版本' })
|
||||||
|
)
|
||||||
|
autoUpdater.on('download-progress', (progress: ProgressInfo) =>
|
||||||
|
this.setState({
|
||||||
|
status: 'downloading',
|
||||||
|
percent: progress.percent,
|
||||||
|
transferred: progress.transferred,
|
||||||
|
total: progress.total,
|
||||||
|
bytesPerSecond: progress.bytesPerSecond
|
||||||
|
})
|
||||||
|
)
|
||||||
|
autoUpdater.on('update-downloaded', (info) =>
|
||||||
|
this.setState({
|
||||||
|
status: 'downloaded',
|
||||||
|
version: info.version,
|
||||||
|
percent: 100,
|
||||||
|
message: '更新已下载'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
autoUpdater.on('error', (error) =>
|
||||||
|
this.setState({ status: 'error', message: error.message || '更新失败' })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): AppUpdateState {
|
||||||
|
return { ...this.state, currentVersion: app.getVersion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(): Promise<AppUpdateCheckResult> {
|
||||||
|
if (!isPackagedRuntime()) {
|
||||||
|
const state = this.setState({
|
||||||
|
status: 'unsupported',
|
||||||
|
message: '开发模式不执行安装包更新,请在正式安装包中检查更新'
|
||||||
|
})
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await autoUpdater.checkForUpdates()
|
||||||
|
if (result?.updateInfo.version) {
|
||||||
|
this.setState({
|
||||||
|
status: 'available',
|
||||||
|
version: result.updateInfo.version,
|
||||||
|
message: '发现新版本'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { success: true, state: this.getState() }
|
||||||
|
} catch (error) {
|
||||||
|
const state = this.setState({
|
||||||
|
status: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(): Promise<AppUpdateCheckResult> {
|
||||||
|
if (!isPackagedRuntime()) {
|
||||||
|
const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' })
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.setState({ status: 'downloading', percent: 0 })
|
||||||
|
await autoUpdater.downloadUpdate()
|
||||||
|
return { success: true, state: this.getState() }
|
||||||
|
} catch (error) {
|
||||||
|
const state = this.setState({
|
||||||
|
status: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install(): { success: boolean; error?: string } {
|
||||||
|
if (this.state.status !== 'downloaded') {
|
||||||
|
return { success: false, error: '更新包尚未下载完成' }
|
||||||
|
}
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
handleState(callback: (state: AppUpdateState) => void): () => void {
|
||||||
|
this.listeners.add(callback)
|
||||||
|
callback(this.getState())
|
||||||
|
return () => this.listeners.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private listeners = new Set<(state: AppUpdateState) => void>()
|
||||||
|
|
||||||
|
private setState(patch: Partial<AppUpdateState>): AppUpdateState {
|
||||||
|
this.state = { ...this.state, ...patch, currentVersion: app.getVersion() }
|
||||||
|
const state = this.getState()
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.webContents.send('app-update:state', state)
|
||||||
|
}
|
||||||
|
for (const listener of this.listeners) listener(state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appUpdateService = new AppUpdateService()
|
||||||
@@ -11,6 +11,19 @@ export interface CachedSelfInfo {
|
|||||||
accountRoot: string
|
accountRoot: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CachedGroupSnapshot {
|
||||||
|
roomId: string
|
||||||
|
memberCount: number
|
||||||
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
interface CachedMessageBucket {
|
interface CachedMessageBucket {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
startTime?: number
|
startTime?: number
|
||||||
@@ -26,11 +39,16 @@ interface BootstrapCacheFile {
|
|||||||
self?: CachedSelfInfo
|
self?: CachedSelfInfo
|
||||||
contacts?: Contact[]
|
contacts?: Contact[]
|
||||||
messages?: Record<string, CachedMessageBucket>
|
messages?: Record<string, CachedMessageBucket>
|
||||||
|
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_VERSION = 1
|
const CACHE_VERSION = 1
|
||||||
const MAX_MESSAGE_BUCKETS = 24
|
const MAX_MESSAGE_BUCKETS = 768
|
||||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
const MAX_MESSAGES_PER_BUCKET = 120
|
||||||
|
const WRITE_DEBOUNCE_MS = 300
|
||||||
|
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||||
|
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||||
|
const writeQueues = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
function normalizeRoot(accountRoot?: string): string {
|
function normalizeRoot(accountRoot?: string): string {
|
||||||
return String(accountRoot || '').trim()
|
return String(accountRoot || '').trim()
|
||||||
@@ -43,27 +61,38 @@ function getCacheFile(accountRoot?: string): string {
|
|||||||
.update(`${process.platform}:${normalizedRoot}`)
|
.update(`${process.platform}:${normalizedRoot}`)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
|
return path.join(
|
||||||
|
app.getPath('userData'),
|
||||||
|
'cache',
|
||||||
|
'bootstrap',
|
||||||
|
`${process.platform}-${hash}.json`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||||
const normalizedRoot = normalizeRoot(accountRoot)
|
const normalizedRoot = normalizeRoot(accountRoot)
|
||||||
if (!normalizedRoot) return null
|
if (!normalizedRoot) return null
|
||||||
const file = getCacheFile(normalizedRoot)
|
const file = getCacheFile(normalizedRoot)
|
||||||
|
const cached = memoryCache.get(file)
|
||||||
|
if (cached) return cached
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(file)) return null
|
if (!fs.existsSync(file)) return null
|
||||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||||
return {
|
const result: BootstrapCacheFile = {
|
||||||
version: CACHE_VERSION,
|
version: CACHE_VERSION,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
accountRoot: normalizedRoot,
|
accountRoot: normalizedRoot,
|
||||||
updatedAt: Number(raw.updatedAt) || 0,
|
updatedAt: Number(raw.updatedAt) || 0,
|
||||||
self: raw.self,
|
self: raw.self,
|
||||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
|
||||||
|
groupSnapshots:
|
||||||
|
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
|
||||||
}
|
}
|
||||||
|
memoryCache.set(file, result)
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[BootstrapCache] read failed:', error)
|
console.warn('[BootstrapCache] read failed:', error)
|
||||||
return null
|
return null
|
||||||
@@ -71,34 +100,82 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||||
try {
|
const file = getCacheFile(cache.accountRoot)
|
||||||
const file = getCacheFile(cache.accountRoot)
|
memoryCache.set(file, cache)
|
||||||
fs.ensureDirSync(path.dirname(file))
|
const existingTimer = writeTimers.get(file)
|
||||||
fs.writeJsonSync(file, cache, { spaces: 2 })
|
if (existingTimer) clearTimeout(existingTimer)
|
||||||
} catch (error) {
|
writeTimers.set(
|
||||||
console.warn('[BootstrapCache] write failed:', error)
|
file,
|
||||||
}
|
setTimeout(() => {
|
||||||
|
writeTimers.delete(file)
|
||||||
|
const serialized = JSON.stringify(memoryCache.get(file) || cache)
|
||||||
|
const previous = writeQueues.get(file) || Promise.resolve()
|
||||||
|
const next = previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
await fs.ensureDir(path.dirname(file))
|
||||||
|
await fs.writeFile(file, serialized, 'utf8')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[BootstrapCache] write failed:', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||||
|
})
|
||||||
|
writeQueues.set(file, next)
|
||||||
|
}, WRITE_DEBOUNCE_MS)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||||
const normalizedRoot = normalizeRoot(accountRoot)
|
const normalizedRoot = normalizeRoot(accountRoot)
|
||||||
if (!normalizedRoot) return null
|
if (!normalizedRoot) return null
|
||||||
return (
|
const existing = readCacheFile(normalizedRoot)
|
||||||
readCacheFile(normalizedRoot) || {
|
if (existing) return existing
|
||||||
version: CACHE_VERSION,
|
const created: BootstrapCacheFile = {
|
||||||
platform: process.platform,
|
version: CACHE_VERSION,
|
||||||
accountRoot: normalizedRoot,
|
platform: process.platform,
|
||||||
updatedAt: Date.now(),
|
accountRoot: normalizedRoot,
|
||||||
contacts: [],
|
updatedAt: Date.now(),
|
||||||
messages: {}
|
contacts: [],
|
||||||
}
|
messages: {},
|
||||||
)
|
groupSnapshots: {}
|
||||||
|
}
|
||||||
|
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||||
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cachedMessageIdentity(message: Message): string {
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.serverId) return `server:${message.serverId}`
|
||||||
|
return `id:${message.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||||
|
return items.some((message) => {
|
||||||
|
const content = message.contentData
|
||||||
|
if (content?.type === 'system' && content.raw) {
|
||||||
|
return (
|
||||||
|
/<weappinfo\b/i.test(content.raw) &&
|
||||||
|
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
content?.type === 'share' &&
|
||||||
|
((content.typeVal === '3' && !content.url) || content.typeVal === '2001')
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (content?.type !== 'sticker') return false
|
||||||
|
const url = String(content.url || content.thumbUrl || '')
|
||||||
|
return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||||
const entries = Object.entries(messages)
|
const entries = Object.entries(messages)
|
||||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||||
@@ -144,7 +221,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
|
|||||||
)
|
)
|
||||||
const nameByUsername = new Map(
|
const nameByUsername = new Map(
|
||||||
cache.contacts
|
cache.contacts
|
||||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
.filter(
|
||||||
|
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||||
|
)
|
||||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||||
)
|
)
|
||||||
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
||||||
@@ -179,7 +258,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
|
|||||||
)
|
)
|
||||||
const nameByUsername = new Map(
|
const nameByUsername = new Map(
|
||||||
(cache.contacts || [])
|
(cache.contacts || [])
|
||||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
.filter(
|
||||||
|
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||||
|
)
|
||||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||||
)
|
)
|
||||||
cache.contacts = contacts.map((contact) => ({
|
cache.contacts = contacts.map((contact) => ({
|
||||||
@@ -223,6 +304,78 @@ export function getCachedMessages(
|
|||||||
return bucket?.items || []
|
return bucket?.items || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCachedMessagePage(
|
||||||
|
accountRoot: string,
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||||
|
const cache = readCacheFile(accountRoot)
|
||||||
|
const key = messageBucketKey(userMd5, startTime, endTime)
|
||||||
|
let bucket = cache?.messages?.[key]
|
||||||
|
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
|
||||||
|
if (!cachedKey.startsWith(`${userMd5}:`)) continue
|
||||||
|
for (const message of candidate.items || []) {
|
||||||
|
merged.set(cachedMessageIdentity(message), message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const migratedMessages = Array.from(merged.values())
|
||||||
|
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
|
||||||
|
.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||||
|
if (migratedMessages.length > 0) {
|
||||||
|
bucket = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
items: migratedMessages
|
||||||
|
}
|
||||||
|
cache.messages[key] = bucket
|
||||||
|
cache.updatedAt = Date.now()
|
||||||
|
pruneMessageBuckets(cache.messages)
|
||||||
|
writeCacheFile(cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||||
|
messages: bucket?.items || [],
|
||||||
|
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCachedGroupSnapshot(
|
||||||
|
accountRoot: string,
|
||||||
|
userMd5: string,
|
||||||
|
snapshot: CachedGroupSnapshot
|
||||||
|
): void {
|
||||||
|
const cache = loadOrCreate(accountRoot)
|
||||||
|
if (!cache) return
|
||||||
|
cache.groupSnapshots ||= {}
|
||||||
|
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
|
||||||
|
cache.updatedAt = Date.now()
|
||||||
|
writeCacheFile(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushBootstrapCacheWritesSync(): void {
|
||||||
|
for (const [file, cache] of memoryCache) {
|
||||||
|
const timer = writeTimers.get(file)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
writeTimers.delete(file)
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(path.dirname(file))
|
||||||
|
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[BootstrapCache] flush failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBootstrapCache(): void {
|
||||||
|
for (const timer of writeTimers.values()) clearTimeout(timer)
|
||||||
|
writeTimers.clear()
|
||||||
|
writeQueues.clear()
|
||||||
|
memoryCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
export function saveCachedMessages(
|
export function saveCachedMessages(
|
||||||
accountRoot: string,
|
accountRoot: string,
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { app, session } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import { clearBootstrapCache } from './bootstrap-cache'
|
||||||
|
import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shared/cache'
|
||||||
|
|
||||||
|
export type { CacheClearScope } from '../../shared/cache'
|
||||||
|
|
||||||
|
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
||||||
|
|
||||||
|
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
|
||||||
|
if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 }
|
||||||
|
let sizeBytes = 0
|
||||||
|
let fileCount = 0
|
||||||
|
const visit = (current: string): void => {
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const target = path.join(current, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
visit(target)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
try {
|
||||||
|
sizeBytes += fs.statSync(target).size
|
||||||
|
fileCount += 1
|
||||||
|
} catch {
|
||||||
|
// A cache file can disappear while it is being inspected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(directory)
|
||||||
|
return { sizeBytes, fileCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCacheSummary(): CacheSummary {
|
||||||
|
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
|
||||||
|
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
|
||||||
|
const items: CacheSummaryItem[] = [
|
||||||
|
{
|
||||||
|
id: 'bootstrap',
|
||||||
|
label: '启动与聊天缓存',
|
||||||
|
description: '联系人、头像、群成员和最近聊天记录的本地副本。',
|
||||||
|
...bootstrap
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electron',
|
||||||
|
label: '应用临时缓存',
|
||||||
|
description: 'Electron 页面资源缓存,清理后会自动重新生成。',
|
||||||
|
...electron
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalBytes: items.reduce((total, item) => total + item.sizeBytes, 0),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCache(scope: CacheClearScope): Promise<CacheSummary> {
|
||||||
|
if (scope === 'bootstrap' || scope === 'all') {
|
||||||
|
clearBootstrapCache()
|
||||||
|
await fs.remove(BOOTSTRAP_CACHE_DIR)
|
||||||
|
}
|
||||||
|
if (scope === 'electron' || scope === 'all') {
|
||||||
|
await session.defaultSession.clearCache()
|
||||||
|
}
|
||||||
|
return getCacheSummary()
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||||
import {
|
import {
|
||||||
|
parseImageBufferDataUrlFromRow,
|
||||||
parseImageDatNameFromRow,
|
parseImageDatNameFromRow,
|
||||||
parseMessageContent,
|
parseMessageContent,
|
||||||
parseStickerMessageFromRow
|
parseStickerMessageFromRow
|
||||||
@@ -8,6 +9,7 @@ import type {
|
|||||||
DatabaseKeyValidationCode,
|
DatabaseKeyValidationCode,
|
||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult
|
||||||
} from '../../shared/database-key'
|
} from '../../shared/database-key'
|
||||||
|
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||||
|
|
||||||
export function getCurrentKey(): string {
|
export function getCurrentKey(): string {
|
||||||
if (!dbRef) return ''
|
if (!dbRef) return ''
|
||||||
@@ -33,6 +35,8 @@ export interface FormattedContact {
|
|||||||
md5: string
|
md5: string
|
||||||
type: 'user' | 'group'
|
type: 'user' | 'group'
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
wechatNickname?: string
|
||||||
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormattedMessage {
|
export interface FormattedMessage {
|
||||||
@@ -48,15 +52,30 @@ export interface FormattedMessage {
|
|||||||
contentData?: ReturnType<typeof parseMessageContent>
|
contentData?: ReturnType<typeof parseMessageContent>
|
||||||
voiceDataUrl?: string
|
voiceDataUrl?: string
|
||||||
voiceDuration?: number
|
voiceDuration?: number
|
||||||
|
exportMediaUrl?: string
|
||||||
|
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||||
|
exportShowAvatar?: boolean
|
||||||
|
exportMediaError?: string
|
||||||
|
exportAvatarUrl?: string
|
||||||
localId?: number
|
localId?: number
|
||||||
|
serverId?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
recalled?: boolean
|
||||||
|
recalledBy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupSnapshot {
|
export interface GroupSnapshot {
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
members: { wxid: string; nickname: string; avatar: string }[]
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const MSG_TYPE_DICT: Record<number, string> = {
|
const MSG_TYPE_DICT: Record<number, string> = {
|
||||||
@@ -119,29 +138,35 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
m_nsNickName: user.nickname || '未知用户',
|
m_nsNickName: user.nickname || '未知用户',
|
||||||
md5,
|
md5,
|
||||||
type: isGroup ? 'group' : 'user',
|
type: isGroup ? 'group' : 'user',
|
||||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
|
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||||
|
wechatNickname: user.wechatNickname,
|
||||||
|
remark: user.remark
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatTables = dbRef.getAllChatTables()
|
// The session list already covers normal conversations. Only scan Chat_*
|
||||||
for (const table of chatTables) {
|
// tables as a recovery fallback when the session query returned nothing.
|
||||||
if (!table.name.startsWith('Chat_')) continue
|
if (userList.length === 0) {
|
||||||
const md5 = table.name.substring(5)
|
const chatTables = dbRef.getAllChatTables()
|
||||||
if (existingMd5s.has(md5)) continue
|
for (const table of chatTables) {
|
||||||
if (groupContacts[md5]) {
|
if (!table.name.startsWith('Chat_')) continue
|
||||||
contacts.push({
|
const md5 = table.name.substring(5)
|
||||||
m_nsUsrName: `Group_${md5}`,
|
if (existingMd5s.has(md5)) continue
|
||||||
m_nsNickName: groupContacts[md5],
|
if (groupContacts[md5]) {
|
||||||
md5,
|
contacts.push({
|
||||||
type: 'group'
|
m_nsUsrName: `Group_${md5}`,
|
||||||
})
|
m_nsNickName: groupContacts[md5],
|
||||||
} else {
|
md5,
|
||||||
contacts.push({
|
type: 'group'
|
||||||
m_nsUsrName: `Unknown_${md5}`,
|
})
|
||||||
m_nsNickName: `Chat_${md5}`,
|
} else {
|
||||||
md5,
|
contacts.push({
|
||||||
type: 'user'
|
m_nsUsrName: `Unknown_${md5}`,
|
||||||
})
|
m_nsNickName: `Chat_${md5}`,
|
||||||
|
md5,
|
||||||
|
type: 'user'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return contacts
|
return contacts
|
||||||
@@ -156,11 +181,12 @@ export function getContactAvatars(usernames: string[]): Record<string, string> {
|
|||||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMessages(
|
function listSourceMessages(
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number,
|
endTime?: number,
|
||||||
options?: { limit?: number }
|
options?: { limit?: number },
|
||||||
|
rawMessagesOverride?: WechatMessage[]
|
||||||
): FormattedMessage[] {
|
): FormattedMessage[] {
|
||||||
if (!dbRef) return []
|
if (!dbRef) return []
|
||||||
|
|
||||||
@@ -171,7 +197,8 @@ export function listMessages(
|
|||||||
console.log(
|
console.log(
|
||||||
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
||||||
)
|
)
|
||||||
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
const rawMessages =
|
||||||
|
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||||
console.log(
|
console.log(
|
||||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||||
)
|
)
|
||||||
@@ -209,17 +236,62 @@ export function listMessages(
|
|||||||
|
|
||||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||||
|
const rawContent = String(content || '')
|
||||||
|
const isPatMessage =
|
||||||
|
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
|
||||||
|
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
|
||||||
|
if (isPatMessage) {
|
||||||
|
const system = parseMessageContent(content, 10000)
|
||||||
|
const patContent =
|
||||||
|
system.type === 'system'
|
||||||
|
? { ...system, pat: true }
|
||||||
|
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
|
||||||
|
contentData = patContent
|
||||||
|
content = patContent.content
|
||||||
|
displayType = '系统消息'
|
||||||
|
}
|
||||||
const inferredMsgType =
|
const inferredMsgType =
|
||||||
typeof content === 'string' &&
|
typeof content === 'string' &&
|
||||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||||
? 49
|
? 49
|
||||||
: msgType
|
: msgType
|
||||||
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||||
try {
|
try {
|
||||||
const parsed =
|
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||||
inferredMsgType === 47
|
const hasStickerPayload =
|
||||||
|
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
||||||
|
/<type>\s*47\s*<\/type>/i.test(content)
|
||||||
|
const rowSticker =
|
||||||
|
inferredMsgType === 47 ||
|
||||||
|
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||||
? parseStickerMessageFromRow(msg, content)
|
? parseStickerMessageFromRow(msg, content)
|
||||||
: parseMessageContent(content, inferredMsgType)
|
: undefined
|
||||||
|
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||||
|
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
|
||||||
|
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
|
||||||
|
const redPacketUrl = rowStickerUrl || parsedShareUrl
|
||||||
|
const isRedPacketFallback =
|
||||||
|
(parsedContent.type === 'share' && parsedContent.typeVal === '2001') ||
|
||||||
|
/wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl)
|
||||||
|
const parsed: ReturnType<typeof parseMessageContent> =
|
||||||
|
parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket'
|
||||||
|
? parsedContent
|
||||||
|
: isRedPacketFallback
|
||||||
|
? {
|
||||||
|
type: 'redPacket',
|
||||||
|
title:
|
||||||
|
parsedContent.type === 'share' && parsedContent.title
|
||||||
|
? parsedContent.title
|
||||||
|
: '微信红包',
|
||||||
|
description:
|
||||||
|
parsedContent.type === 'share' && parsedContent.des
|
||||||
|
? parsedContent.des
|
||||||
|
: '恭喜发财,大吉大利',
|
||||||
|
url: redPacketUrl || undefined
|
||||||
|
}
|
||||||
|
: rowSticker?.type === 'sticker'
|
||||||
|
? rowSticker
|
||||||
|
: parsedContent
|
||||||
if (parsed.type === 'system') {
|
if (parsed.type === 'system') {
|
||||||
content = parsed.content
|
content = parsed.content
|
||||||
contentData = parsed
|
contentData = parsed
|
||||||
@@ -229,6 +301,13 @@ export function listMessages(
|
|||||||
if (parsed.type === 'image') {
|
if (parsed.type === 'image') {
|
||||||
const imageDatName = parseImageDatNameFromRow(msg)
|
const imageDatName = parseImageDatNameFromRow(msg)
|
||||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||||
|
} else if (parsed.type === 'miniProgram') {
|
||||||
|
contentData = {
|
||||||
|
...parsed,
|
||||||
|
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||||
|
thumbDataUrl:
|
||||||
|
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||||
|
}
|
||||||
} else if (parsed.type !== 'system') {
|
} else if (parsed.type !== 'system') {
|
||||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||||
@@ -238,6 +317,17 @@ export function listMessages(
|
|||||||
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
||||||
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
||||||
}
|
}
|
||||||
|
if (parsed.type === 'quote') displayType = '引用消息'
|
||||||
|
if (parsed.type === 'sticker') displayType = '表情包'
|
||||||
|
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||||
|
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||||
|
if (parsed.type === 'share') {
|
||||||
|
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||||
|
if (parsed.typeVal === '6') displayType = '文件'
|
||||||
|
if (parsed.typeVal === '74') displayType = '文件发送中'
|
||||||
|
if (parsed.typeVal === '51') displayType = '视频号'
|
||||||
|
if (parsed.typeVal === '2000') displayType = '转账'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
@@ -257,8 +347,12 @@ export function listMessages(
|
|||||||
|
|
||||||
if (msgType === 34) content = '[语音消息]'
|
if (msgType === 34) content = '[语音消息]'
|
||||||
|
|
||||||
|
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: msg.mesLocalID || Math.random().toString(),
|
id: recoveredFromRecallJournal
|
||||||
|
? `recovered:${msg.mesLocalID || msg.serverId || createTime}`
|
||||||
|
: msg.mesLocalID || Math.random().toString(),
|
||||||
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||||
isSender: isMine,
|
isSender: isMine,
|
||||||
type: displayType,
|
type: displayType,
|
||||||
@@ -269,7 +363,9 @@ export function listMessages(
|
|||||||
senderId,
|
senderId,
|
||||||
sessionId: username,
|
sessionId: username,
|
||||||
localId,
|
localId,
|
||||||
|
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
|
||||||
createTime,
|
createTime,
|
||||||
|
recoveredFromRecallJournal,
|
||||||
contentData
|
contentData
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -280,6 +376,39 @@ export function listMessages(
|
|||||||
return formatted
|
return formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listMessages(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): FormattedMessage[] {
|
||||||
|
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options)
|
||||||
|
if (!dbRef) return sourceMessages
|
||||||
|
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||||
|
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||||
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMessagesAsync(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): Promise<FormattedMessage[]> {
|
||||||
|
if (!dbRef) return []
|
||||||
|
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
|
const sourceMessages = listSourceMessages(
|
||||||
|
userMd5,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
options,
|
||||||
|
rawMessages
|
||||||
|
)
|
||||||
|
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||||
|
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||||
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
|
}
|
||||||
|
|
||||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
const wcdb4Client = dbRef.getWcdb4Client()
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
@@ -292,12 +421,34 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
|||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
wxid: member.m_nsUsrName,
|
wxid: member.m_nsUsrName,
|
||||||
nickname: member.nickname || '',
|
nickname: member.nickname || '',
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
wechatNickname: member.wechatNickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
avatar: member.m_nsHeadImgUrl || ''
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { roomId, memberCount: members.length, members }
|
return { roomId, memberCount: members.length, members }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
||||||
|
if (!dbRef) return null
|
||||||
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
|
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||||
|
|
||||||
|
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
|
||||||
|
.filter((member) => member?.m_nsUsrName)
|
||||||
|
.map((member) => ({
|
||||||
|
wxid: member.m_nsUsrName,
|
||||||
|
nickname: member.nickname || '',
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
wechatNickname: member.wechatNickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
|
}))
|
||||||
|
return { roomId, memberCount: members.length, members }
|
||||||
|
}
|
||||||
|
|
||||||
export function searchMessages(keyword: string): string | null {
|
export function searchMessages(keyword: string): string | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
return dbRef.searchAllMessages(keyword)
|
return dbRef.searchAllMessages(keyword)
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status'
|
|||||||
export async function inspectImageDecryptionStatus(
|
export async function inspectImageDecryptionStatus(
|
||||||
config: ImageKeyConfigResult
|
config: ImageKeyConfigResult
|
||||||
): Promise<ImageDecryptionStatus> {
|
): Promise<ImageDecryptionStatus> {
|
||||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
|
// 状态面板的"图片资源目录"始终等于当前识别到的微信账号根目录;
|
||||||
|
// 仅在微信未连接时回退到上次配置中的 resourceRoot,避免空白。
|
||||||
|
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot || ''
|
||||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||||
const stickerCacheFound =
|
const stickerCacheFound =
|
||||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||||
@@ -65,7 +67,10 @@ export function testImageDecryption(
|
|||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.contentData?.type === 'image')
|
.find((message) => message.contentData?.type === 'image')
|
||||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||||
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
|
return failure(
|
||||||
|
'NO_IMAGE_MESSAGE',
|
||||||
|
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = new ImageDecryptService(
|
const service = new ImageDecryptService(
|
||||||
@@ -74,26 +79,51 @@ export function testImageDecryption(
|
|||||||
chat.getChatDb()?.getWcdb4Client()
|
chat.getChatDb()?.getWcdb4Client()
|
||||||
)
|
)
|
||||||
const image = imageMessage.contentData
|
const image = imageMessage.contentData
|
||||||
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
|
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
|
||||||
|
// 找不到再退回默认 accountDir。
|
||||||
|
const testAccountDir = normalized.resourceRoot || undefined
|
||||||
|
let filePath = service.findImageFile(image.md5, image.datName, {
|
||||||
|
allowThumbnail: false,
|
||||||
|
accountDir: testAccountDir
|
||||||
|
})
|
||||||
if (!filePath)
|
if (!filePath)
|
||||||
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
|
filePath = service.findImageFile(image.md5, image.datName, {
|
||||||
|
allowThumbnail: true,
|
||||||
|
accountDir: testAccountDir
|
||||||
|
})
|
||||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||||
|
|
||||||
const data = service.decryptImageToBase64(filePath)
|
const data = service.decryptImageToBase64(filePath)
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
|
||||||
return {
|
return {
|
||||||
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
|
success: false,
|
||||||
fileFound: true
|
code: 'DECRYPT_FAILED',
|
||||||
|
error: '无法解析媒体文件',
|
||||||
|
fileFound: false,
|
||||||
|
decrypted: false,
|
||||||
|
readable: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const readable = data.startsWith('data:image/')
|
const readable = data.startsWith('data:image/')
|
||||||
|
if (!readable) {
|
||||||
|
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
|
||||||
|
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: 'DECRYPT_FAILED',
|
||||||
|
error: '图片解密结果不可读取',
|
||||||
|
fileFound: true,
|
||||||
|
decrypted: false,
|
||||||
|
readable: false,
|
||||||
|
isThumbnail: service.isThumbnailFile(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: readable,
|
success: true,
|
||||||
code: readable ? undefined : 'DECRYPT_FAILED',
|
|
||||||
error: readable ? undefined : '图片解密结果不可读取',
|
|
||||||
fileFound: true,
|
fileFound: true,
|
||||||
decrypted: true,
|
decrypted: true,
|
||||||
readable,
|
readable: true,
|
||||||
isThumbnail: service.isThumbnailFile(filePath)
|
isThumbnail: service.isThumbnailFile(filePath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// src/main/services/image-insight-prompt.ts
|
||||||
|
// 图片理解 prompt 模板 — 输出严格的 JSON,便于程序化解析
|
||||||
|
|
||||||
|
export const IMAGE_ANALYSIS_SYSTEM_PROMPT = `你是微信群聊的图片分析助手。
|
||||||
|
请根据用户提供的图片和图片前后的聊天上下文,生成对该图片的结构化理解。
|
||||||
|
|
||||||
|
输出要求(严格遵守):
|
||||||
|
1. 必须输出 JSON,不要用 markdown 代码块包裹
|
||||||
|
2. description:1-2 句中文,30-80 字,描述图片核心内容
|
||||||
|
3. ocrText:如果图片含文字(截图、文档、票据等),提取出来;纯风景/表情包可填空字符串
|
||||||
|
4. tags:3-6 个中文关键词标签
|
||||||
|
5. category:screenshot / photo / meme / document / chart / other 之一
|
||||||
|
6. importance:low / medium / high — 根据图片的信息密度和后续讨论热度判断
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
- 不要猜测图片中未明确可见的内容
|
||||||
|
- 不要复述聊天上下文本身(那是 description 之外的事)
|
||||||
|
- 不要输出 markdown 标记`
|
||||||
|
|
||||||
|
export interface ImageAnalysisContext {
|
||||||
|
sender: string
|
||||||
|
sentAt: number
|
||||||
|
contextBefore: string[] // 图片前 1-3 条消息
|
||||||
|
contextAfter: string[] // 图片后 1-3 条消息
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildImageAnalysisUserText(ctx: ImageAnalysisContext): string {
|
||||||
|
const before = ctx.contextBefore.length
|
||||||
|
? ctx.contextBefore.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
|
||||||
|
: ' (无前文)'
|
||||||
|
const after = ctx.contextAfter.length
|
||||||
|
? ctx.contextAfter.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
|
||||||
|
: ' (无后续讨论)'
|
||||||
|
const time = new Date(ctx.sentAt * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
|
||||||
|
return `发送者:${ctx.sender}
|
||||||
|
时间:${time}
|
||||||
|
|
||||||
|
图片前的聊天:
|
||||||
|
${before}
|
||||||
|
|
||||||
|
图片后的聊天:
|
||||||
|
${after}
|
||||||
|
|
||||||
|
请输出 JSON(严格遵守 system 要求):
|
||||||
|
{"description":"...","ocrText":"...","tags":["..."],"category":"...","importance":"..."}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 AI 文本响应解析成结构化字段。
|
||||||
|
* 容忍:无 markdown 包裹、有 markdown 包裹、尾部有杂质等。
|
||||||
|
*/
|
||||||
|
export function parseImageAnalysisResponse(raw: string): {
|
||||||
|
description: string
|
||||||
|
ocrText: string
|
||||||
|
tags: string[]
|
||||||
|
category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other'
|
||||||
|
importance: 'low' | 'medium' | 'high'
|
||||||
|
} {
|
||||||
|
const text = raw.trim()
|
||||||
|
// 提取 JSON 段
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) {
|
||||||
|
throw new Error('AI 未返回合法 JSON')
|
||||||
|
}
|
||||||
|
let parsed: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonMatch[0])
|
||||||
|
} catch {
|
||||||
|
throw new Error('AI 返回的 JSON 无法解析')
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = String(parsed.description || '').trim()
|
||||||
|
if (!description) throw new Error('AI 未返回 description')
|
||||||
|
|
||||||
|
const ocrText = String(parsed.ocrText || '').trim()
|
||||||
|
const tagsRaw = parsed.tags
|
||||||
|
const tags = Array.isArray(tagsRaw)
|
||||||
|
? tagsRaw.map((t) => String(t).trim()).filter(Boolean).slice(0, 8)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const categoryRaw = String(parsed.category || 'other').toLowerCase()
|
||||||
|
const category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other' =
|
||||||
|
['screenshot', 'photo', 'meme', 'document', 'chart'].includes(categoryRaw)
|
||||||
|
? (categoryRaw as 'screenshot' | 'photo' | 'meme' | 'document' | 'chart')
|
||||||
|
: 'other'
|
||||||
|
|
||||||
|
const importanceRaw = String(parsed.importance || 'medium').toLowerCase()
|
||||||
|
const importance: 'low' | 'medium' | 'high' = ['low', 'medium', 'high'].includes(importanceRaw)
|
||||||
|
? (importanceRaw as 'low' | 'medium' | 'high')
|
||||||
|
: 'medium'
|
||||||
|
|
||||||
|
return { description, ocrText, tags, category, importance }
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// src/main/services/image-insight-service.ts
|
||||||
|
// WechatExplorer AI 图片理解基础设施
|
||||||
|
//
|
||||||
|
// 设计原则:
|
||||||
|
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
||||||
|
// 2. 同图(imageHash)走缓存,绝不重复调 AI
|
||||||
|
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
|
||||||
|
// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI
|
||||||
|
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { imageInsightsStore } from '../db/image-insights-store'
|
||||||
|
import {
|
||||||
|
buildImageAnalysisUserText,
|
||||||
|
IMAGE_ANALYSIS_SYSTEM_PROMPT,
|
||||||
|
parseImageAnalysisResponse
|
||||||
|
} from './image-insight-prompt'
|
||||||
|
import type {
|
||||||
|
ImageAnalysisRequest,
|
||||||
|
ImageAnalysisResponse,
|
||||||
|
ImageCandidate,
|
||||||
|
ImageCandidateQuery,
|
||||||
|
ImageInsight
|
||||||
|
} from '../../shared/image-insight'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。
|
||||||
|
* 这样可以避免 ImageInsightService 自己重新查询消息,且参数语义清晰。
|
||||||
|
*/
|
||||||
|
export interface ImageCandidateInput {
|
||||||
|
messageId: string
|
||||||
|
md5?: string
|
||||||
|
datName?: string
|
||||||
|
sessionId: string
|
||||||
|
sender: string
|
||||||
|
sentAt: number
|
||||||
|
/** 图片发出后 8 条消息内、不同发言人的回复数(由 renderer 计算) */
|
||||||
|
responseCount: number
|
||||||
|
/** 表情/语音互动条数 */
|
||||||
|
interactionCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderServiceLike {
|
||||||
|
list(): ProviderSummaryLike
|
||||||
|
analyzeImage(
|
||||||
|
messages: Array<{
|
||||||
|
role: string
|
||||||
|
content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
|
||||||
|
}>,
|
||||||
|
options?: { providerId?: string; modelId?: string }
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: string
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecryptServiceLike {
|
||||||
|
findImageFile(
|
||||||
|
md5?: string,
|
||||||
|
imageDatName?: string,
|
||||||
|
options?: { allowThumbnail?: boolean }
|
||||||
|
): string | null
|
||||||
|
decryptImageToBase64(datPath: string): string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderSummaryLike {
|
||||||
|
providers: Array<{
|
||||||
|
id: string
|
||||||
|
isDefault: boolean
|
||||||
|
defaultModel: string
|
||||||
|
models: Array<{ id: string; capabilities: { vision: boolean; ocr: boolean } }>
|
||||||
|
}>
|
||||||
|
defaultProviderId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageInsightService {
|
||||||
|
private providerService: ProviderServiceLike | null = null
|
||||||
|
private decryptService: DecryptServiceLike | null = null
|
||||||
|
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
|
||||||
|
private runtimeProviderId: string | undefined = undefined
|
||||||
|
private runtimeModelId: string | undefined = undefined
|
||||||
|
|
||||||
|
/** 注入依赖(由 main/index.ts 在 app ready 后调用) */
|
||||||
|
bind(deps: {
|
||||||
|
providerService: ProviderServiceLike & { list(): ProviderSummaryLike }
|
||||||
|
decryptService: DecryptServiceLike
|
||||||
|
}): void {
|
||||||
|
this.providerService = deps.providerService
|
||||||
|
this.decryptService = deps.decryptService
|
||||||
|
console.log(
|
||||||
|
'[ImageInsightService] bind ok, default provider=%s model=%s',
|
||||||
|
this.runtimeProviderId,
|
||||||
|
this.runtimeModelId
|
||||||
|
)
|
||||||
|
// 读取默认 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
|
||||||
|
console.log(
|
||||||
|
'[ImageInsightService] bind loaded default provider=%s model=%s',
|
||||||
|
this.runtimeProviderId,
|
||||||
|
this.runtimeModelId
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ImageInsightService] bind list failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算图片缓存 key:imageHash。
|
||||||
|
* 策略:优先微信原始 md5,无 md5 才用 sha256(rawBytes).slice(0, 32)
|
||||||
|
*/
|
||||||
|
private async computeImageHash(
|
||||||
|
md5: string | undefined,
|
||||||
|
datName: string | undefined
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (md5 && md5.trim()) return md5.trim().toLowerCase()
|
||||||
|
if (!this.decryptService) return null
|
||||||
|
const filePath = this.decryptService.findImageFile(undefined, datName, { allowThumbnail: true })
|
||||||
|
if (!filePath) return null
|
||||||
|
// 一次性读盘 + sha256(只在没有 md5 时才付出 IO)
|
||||||
|
try {
|
||||||
|
const fs = await import('fs-extra')
|
||||||
|
const buf = await fs.readFile(filePath)
|
||||||
|
const sha = crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)
|
||||||
|
return `sha256:${sha}`
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ImageInsightService] computeImageHash failed:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过 hash 拿 Insight(只读缓存,无 AI 调用) */
|
||||||
|
getInsight(imageHash: string): ImageInsight | null {
|
||||||
|
return imageInsightsStore.getByHash(imageHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主入口:分析一张图片。
|
||||||
|
* 1. 通过 imageHash 查缓存,命中即返回
|
||||||
|
* 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
|
||||||
|
* 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
|
||||||
|
*/
|
||||||
|
async analyze(request: ImageAnalysisRequest): Promise<ImageAnalysisResponse> {
|
||||||
|
try {
|
||||||
|
if (!request.force) {
|
||||||
|
const cached = imageInsightsStore.getByHash(request.imageHash)
|
||||||
|
if (cached) {
|
||||||
|
return { success: true, insight: cached, fromCache: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.providerService) {
|
||||||
|
return { success: false, error: 'AI Provider 未初始化' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: IMAGE_ANALYSIS_SYSTEM_PROMPT
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: buildImageAnalysisUserText({
|
||||||
|
sender: request.sender,
|
||||||
|
sentAt: request.sentAt,
|
||||||
|
contextBefore: [],
|
||||||
|
contextAfter: []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ type: 'image' as const, dataUrl: request.imageDataUrl }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
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 result = await this.providerService.analyzeImage(messages, {
|
||||||
|
providerId: this.runtimeProviderId,
|
||||||
|
modelId: this.runtimeModelId
|
||||||
|
})
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.warn('[ImageInsightService] analyze vision failed: %s', result.error || 'no data')
|
||||||
|
return { success: false, error: result.error || 'AI 未返回内容' }
|
||||||
|
}
|
||||||
|
console.log('[ImageInsightService] analyze ok, description=%s', result.data.slice(0, 80))
|
||||||
|
|
||||||
|
const parsed = parseImageAnalysisResponse(result.data)
|
||||||
|
const insight: ImageInsight = {
|
||||||
|
id: randomUUID(),
|
||||||
|
messageId: request.messageId,
|
||||||
|
imageHash: request.imageHash,
|
||||||
|
md5: undefined,
|
||||||
|
datName: undefined,
|
||||||
|
description: parsed.description,
|
||||||
|
ocrText: parsed.ocrText || undefined,
|
||||||
|
tags: parsed.tags,
|
||||||
|
category: parsed.category,
|
||||||
|
importance: parsed.importance,
|
||||||
|
provider: this.runtimeProviderId || '',
|
||||||
|
model: this.runtimeModelId || '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
sender: request.sender,
|
||||||
|
sentAt: request.sentAt,
|
||||||
|
sessionId: request.sessionId
|
||||||
|
}
|
||||||
|
imageInsightsStore.upsert(insight)
|
||||||
|
return { success: true, insight, fromCache: false }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
console.warn('[ImageInsightService] analyze failed:', message)
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日报入口:从 renderer 传入的图片消息候选中挑 Top N + 命中缓存的 Insight。
|
||||||
|
*
|
||||||
|
* 设计:不自己查 chat-service(参数语义不清),而是由 renderer 从已加载的 messages 中
|
||||||
|
* 提取图片消息 + 计算热度后传入。这样既复用现有数据,又避免 userMd5/sessionId 混淆。
|
||||||
|
*/
|
||||||
|
async listTopHotImages(
|
||||||
|
query: ImageCandidateQuery,
|
||||||
|
inputs: ImageCandidateInput[] = []
|
||||||
|
): Promise<ImageCandidate[]> {
|
||||||
|
const limit = query.limit ?? 3
|
||||||
|
const candidates: ImageCandidate[] = []
|
||||||
|
console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length)
|
||||||
|
for (const input of inputs) {
|
||||||
|
const hash = await this.computeImageHash(input.md5, input.datName)
|
||||||
|
if (!hash) {
|
||||||
|
console.log(
|
||||||
|
'[ImageInsightService] skip %s: hash empty (md5=%s datName=%s)',
|
||||||
|
input.messageId,
|
||||||
|
input.md5,
|
||||||
|
input.datName
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1
|
||||||
|
const candidate: ImageCandidate = {
|
||||||
|
messageId: input.messageId,
|
||||||
|
imageHash: hash,
|
||||||
|
md5: input.md5,
|
||||||
|
datName: input.datName,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
sender: input.sender,
|
||||||
|
sentAt: input.sentAt,
|
||||||
|
heatScore
|
||||||
|
}
|
||||||
|
const cached = imageInsightsStore.getByHash(hash)
|
||||||
|
if (cached) candidate.insight = cached
|
||||||
|
candidates.push(candidate)
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => b.heatScore - a.heatScore)
|
||||||
|
return candidates.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出会话所有 insights(按时间倒序,供未来 UI 复用)
|
||||||
|
*/
|
||||||
|
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||||
|
return imageInsightsStore.listBySession(sessionId, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageInsightService = new ImageInsightService()
|
||||||
@@ -92,9 +92,10 @@ export class ImageKeyConfigService {
|
|||||||
if (!result.success || !result.entry) {
|
if (!result.success || !result.entry) {
|
||||||
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
||||||
}
|
}
|
||||||
|
// 注意:normalized.resourceRoot 不再写回 imageKeyRoot。
|
||||||
|
// 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。
|
||||||
saveSettings({
|
saveSettings({
|
||||||
...settings,
|
...settings,
|
||||||
imageKeyRoot: normalized.resourceRoot,
|
|
||||||
imageXorKey: '',
|
imageXorKey: '',
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
imageKeyFallbackDisabled: false
|
imageKeyFallbackDisabled: false
|
||||||
@@ -106,7 +107,9 @@ export class ImageKeyConfigService {
|
|||||||
encryptionAvailable: true,
|
encryptionAvailable: true,
|
||||||
source: 'secure-storage',
|
source: 'secure-storage',
|
||||||
accountId: context.accountId,
|
accountId: context.accountId,
|
||||||
resourceRoot: normalized.resourceRoot,
|
// 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致;
|
||||||
|
// 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。
|
||||||
|
resourceRoot: context.resourceRoot,
|
||||||
xorKey: result.entry.xorKey,
|
xorKey: result.entry.xorKey,
|
||||||
aesKey: result.entry.aesKey,
|
aesKey: result.entry.aesKey,
|
||||||
updatedAt: result.entry.updatedAt
|
updatedAt: result.entry.updatedAt
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../shared/local-api-test'
|
} from '../../shared/local-api-test'
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 10_000
|
const REQUEST_TIMEOUT_MS = 10_000
|
||||||
|
const GROUP_REPORT_TIMEOUT_MS = 180_000
|
||||||
const MAX_BODY_SIZE = 512 * 1024
|
const MAX_BODY_SIZE = 512 * 1024
|
||||||
|
|
||||||
function isEndpointId(value: unknown): value is LocalApiEndpointId {
|
function isEndpointId(value: unknown): value is LocalApiEndpointId {
|
||||||
@@ -122,7 +123,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
const timeoutMs =
|
||||||
|
endpointId === 'agent-group-report' ? GROUP_REPORT_TIMEOUT_MS : REQUEST_TIMEOUT_MS
|
||||||
|
request.setTimeout(timeoutMs, () => {
|
||||||
request.destroy(new Error('请求超时'))
|
request.destroy(new Error('请求超时'))
|
||||||
finish({
|
finish({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -132,7 +135,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
responseSize: 0,
|
responseSize: 0,
|
||||||
errorCode: 'TIMEOUT',
|
errorCode: 'TIMEOUT',
|
||||||
error: '请求超时(10 秒)'
|
error: `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
request.on('error', (error: NodeJS.ErrnoException) => {
|
request.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Message } from '../../shared/types'
|
||||||
|
|
||||||
|
type RecallRecord = {
|
||||||
|
targetIds: string[]
|
||||||
|
actor?: string
|
||||||
|
noticeId: string
|
||||||
|
createTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchivedMessage = Message & {
|
||||||
|
archivedAt: number
|
||||||
|
recallNotice?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionArchive = {
|
||||||
|
username: string
|
||||||
|
updatedAt: number
|
||||||
|
messages: ArchivedMessage[]
|
||||||
|
recalls: RecallRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecallArchiveFile = {
|
||||||
|
version: 1
|
||||||
|
accountRoot: string
|
||||||
|
updatedAt: number
|
||||||
|
sessions: Record<string, SessionArchive>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARCHIVE_VERSION = 1
|
||||||
|
const MAX_MESSAGES_PER_SESSION = 3000
|
||||||
|
const MAX_RECALLS_PER_SESSION = 500
|
||||||
|
const WRITE_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
|
let archive: RecallArchiveFile | null = null
|
||||||
|
let archivePath = ''
|
||||||
|
let writeTimer: NodeJS.Timeout | null = null
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
function messageIdentity(message: Message): string {
|
||||||
|
if (message.recoveredFromRecallJournal) {
|
||||||
|
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
||||||
|
}
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.serverId) return `server:${message.serverId}`
|
||||||
|
if (message.id) return `id:${message.id}`
|
||||||
|
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageTargetIds(message: Message): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
message.serverId,
|
||||||
|
message.localId ? String(message.localId) : '',
|
||||||
|
message.id ? String(message.id) : ''
|
||||||
|
].filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecallNotice(message: Message): boolean {
|
||||||
|
return message.contentData?.type === 'system' && Boolean(message.contentData.recall)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRecall(message: Message, recall: RecallRecord): boolean {
|
||||||
|
const messageIds = messageTargetIds(message)
|
||||||
|
return recall.targetIds.some((targetId) => messageIds.includes(targetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRecallTarget(
|
||||||
|
messages: Iterable<ArchivedMessage>,
|
||||||
|
notice: Message,
|
||||||
|
actor?: string,
|
||||||
|
claimedTargetIds: Set<string> = new Set()
|
||||||
|
): ArchivedMessage | undefined {
|
||||||
|
const noticeTime = notice.createTime || 0
|
||||||
|
const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content)
|
||||||
|
const eligible = Array.from(messages)
|
||||||
|
.filter((message) => {
|
||||||
|
if (message.type === '系统消息' || message.from === 'system' || message.recallNotice)
|
||||||
|
return false
|
||||||
|
if (!message.recoveredFromRecallJournal) return false
|
||||||
|
if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false
|
||||||
|
if (noticeTime && message.createTime && message.createTime > noticeTime) return false
|
||||||
|
if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDelta = (right.createTime || 0) - (left.createTime || 0)
|
||||||
|
if (timeDelta) return timeDelta
|
||||||
|
return (right.localId || 0) - (left.localId || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const strict = eligible.find((message) => {
|
||||||
|
if (expectsMine) return message.isSender
|
||||||
|
if (!actor) return true
|
||||||
|
const names = [message.name, message.senderId].map((value) => String(value || '').trim())
|
||||||
|
return !names.some(Boolean) || names.includes(actor)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Journal rows are captured at deletion time, so the fallback never points at
|
||||||
|
// an unrelated visible message merely because it happened to be nearby.
|
||||||
|
return strict || eligible[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message {
|
||||||
|
if (isRecallNotice(message)) return message
|
||||||
|
const recall = recalls.find((item) => matchesRecall(message, item))
|
||||||
|
if (!recall) return message
|
||||||
|
const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim())
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
from: recalledByMe ? 'assistant' : message.from,
|
||||||
|
isSender: recalledByMe ? true : message.isSender,
|
||||||
|
recalled: true,
|
||||||
|
recalledBy: recall.actor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWrite(): void {
|
||||||
|
if (!archive || !archivePath) return
|
||||||
|
if (writeTimer) clearTimeout(writeTimer)
|
||||||
|
writeTimer = setTimeout(() => {
|
||||||
|
writeTimer = null
|
||||||
|
if (!archive || !archivePath) return
|
||||||
|
const targetPath = archivePath
|
||||||
|
const serialized = JSON.stringify(archive)
|
||||||
|
writeQueue = writeQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
await fs.ensureDir(path.dirname(targetPath))
|
||||||
|
const temporaryPath = `${targetPath}.tmp`
|
||||||
|
await fs.writeFile(temporaryPath, serialized, 'utf8')
|
||||||
|
await fs.move(temporaryPath, targetPath, { overwrite: true })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[RecallArchive] write failed:', error)
|
||||||
|
})
|
||||||
|
}, WRITE_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadArchive(accountRoot: string): RecallArchiveFile {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(`${process.platform}:${accountRoot}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)
|
||||||
|
archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(archivePath)) {
|
||||||
|
const stored = fs.readJsonSync(archivePath) as Partial<RecallArchiveFile>
|
||||||
|
if (
|
||||||
|
stored.version === ARCHIVE_VERSION &&
|
||||||
|
stored.accountRoot === accountRoot &&
|
||||||
|
stored.sessions &&
|
||||||
|
typeof stored.sessions === 'object'
|
||||||
|
) {
|
||||||
|
return stored as RecallArchiveFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[RecallArchive] read failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: ARCHIVE_VERSION,
|
||||||
|
accountRoot,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
sessions: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureRecallArchive(accountRoot: string): void {
|
||||||
|
const normalizedRoot = String(accountRoot || '').trim()
|
||||||
|
if (!normalizedRoot) {
|
||||||
|
archive = null
|
||||||
|
archivePath = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (archive?.accountRoot === normalizedRoot) return
|
||||||
|
archive = loadArchive(normalizedRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecallArchiveMessages(
|
||||||
|
sessionMd5: string,
|
||||||
|
username: string,
|
||||||
|
messages: Message[]
|
||||||
|
): void {
|
||||||
|
if (!archive || !sessionMd5 || messages.length === 0) return
|
||||||
|
|
||||||
|
const bucket =
|
||||||
|
archive.sessions[sessionMd5] ||
|
||||||
|
({
|
||||||
|
username,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
recalls: []
|
||||||
|
} satisfies SessionArchive)
|
||||||
|
if (username) bucket.username = username
|
||||||
|
|
||||||
|
const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message]))
|
||||||
|
let changed = false
|
||||||
|
const targetlessNoticeIds = new Set(
|
||||||
|
messages
|
||||||
|
.filter((message) => {
|
||||||
|
const recall =
|
||||||
|
message.contentData?.type === 'system' ? message.contentData.recall : undefined
|
||||||
|
return Boolean(recall && !(recall.targetIds?.length || recall.targetId))
|
||||||
|
})
|
||||||
|
.map(messageIdentity)
|
||||||
|
)
|
||||||
|
if (targetlessNoticeIds.size > 0) {
|
||||||
|
const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId))
|
||||||
|
if (retained.length !== bucket.recalls.length) {
|
||||||
|
bucket.recalls = retained
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceMessage of messages) {
|
||||||
|
const recallData =
|
||||||
|
sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined
|
||||||
|
if (recallData) {
|
||||||
|
let targetIds = Array.from(
|
||||||
|
new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean))
|
||||||
|
)
|
||||||
|
const noticeId = messageIdentity(sourceMessage)
|
||||||
|
const noticeTargetIds = new Set(messageTargetIds(sourceMessage))
|
||||||
|
const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId)
|
||||||
|
const existingRecord =
|
||||||
|
existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined
|
||||||
|
const existingTargetsNotice =
|
||||||
|
existingRecord &&
|
||||||
|
existingRecord.targetIds.length > 0 &&
|
||||||
|
existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId))
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetIds.length === 0 &&
|
||||||
|
existingRecord &&
|
||||||
|
!existingTargetsNotice &&
|
||||||
|
!targetlessNoticeIds.has(noticeId)
|
||||||
|
) {
|
||||||
|
targetIds = existingRecord.targetIds
|
||||||
|
}
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds))
|
||||||
|
const inferred = inferRecallTarget(
|
||||||
|
byIdentity.values(),
|
||||||
|
sourceMessage,
|
||||||
|
recallData.actor,
|
||||||
|
claimedTargetIds
|
||||||
|
)
|
||||||
|
if (inferred) targetIds = messageTargetIds(inferred)
|
||||||
|
}
|
||||||
|
if (targetIds.length > 0) {
|
||||||
|
const nextRecord: RecallRecord = {
|
||||||
|
targetIds,
|
||||||
|
actor: recallData.actor,
|
||||||
|
noticeId,
|
||||||
|
createTime: sourceMessage.createTime || 0
|
||||||
|
}
|
||||||
|
const unchanged =
|
||||||
|
existingRecord &&
|
||||||
|
existingRecord.targetIds.length === targetIds.length &&
|
||||||
|
existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) &&
|
||||||
|
existingRecord.actor === nextRecord.actor
|
||||||
|
if (!unchanged) {
|
||||||
|
if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord)
|
||||||
|
else bucket.recalls.push(nextRecord)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotated = annotateFromRecall(sourceMessage, bucket.recalls)
|
||||||
|
const next: ArchivedMessage = {
|
||||||
|
...annotated,
|
||||||
|
archivedAt: Date.now(),
|
||||||
|
recallNotice: isRecallNotice(sourceMessage)
|
||||||
|
}
|
||||||
|
const identity = messageIdentity(next)
|
||||||
|
const previous = byIdentity.get(identity)
|
||||||
|
if (
|
||||||
|
!previous ||
|
||||||
|
previous.recalled !== next.recalled ||
|
||||||
|
previous.content !== next.content ||
|
||||||
|
previous.serverId !== next.serverId
|
||||||
|
) {
|
||||||
|
byIdentity.set(identity, next)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION)
|
||||||
|
const nextMessages = Array.from(byIdentity.values())
|
||||||
|
.map((message) => annotateFromRecall(message, recalls) as ArchivedMessage)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||||
|
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||||
|
})
|
||||||
|
.slice(-MAX_MESSAGES_PER_SESSION)
|
||||||
|
|
||||||
|
if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return
|
||||||
|
|
||||||
|
bucket.messages = nextMessages
|
||||||
|
bucket.recalls = recalls
|
||||||
|
bucket.updatedAt = Date.now()
|
||||||
|
archive.sessions[sessionMd5] = bucket
|
||||||
|
archive.updatedAt = Date.now()
|
||||||
|
scheduleWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRecallArchiveMessages<T extends Message>(
|
||||||
|
sessionMd5: string,
|
||||||
|
sourceMessages: T[],
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
|
): T[] {
|
||||||
|
const bucket = archive?.sessions[sessionMd5]
|
||||||
|
if (!bucket) return sourceMessages
|
||||||
|
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const message of sourceMessages) {
|
||||||
|
const archived = bucket.messages.find(
|
||||||
|
(candidate) => messageIdentity(candidate) === messageIdentity(message)
|
||||||
|
)
|
||||||
|
merged.set(
|
||||||
|
messageIdentity(message),
|
||||||
|
annotateFromRecall(
|
||||||
|
archived?.recalled
|
||||||
|
? { ...message, recalled: true, recalledBy: archived.recalledBy }
|
||||||
|
: message,
|
||||||
|
bucket.recalls
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const archived of bucket.messages) {
|
||||||
|
if (!archived.recalled && !archived.recallNotice) continue
|
||||||
|
const createTime = archived.createTime || 0
|
||||||
|
if (startTime && createTime < startTime) continue
|
||||||
|
if (endTime && createTime > endTime) continue
|
||||||
|
const identity = messageIdentity(archived)
|
||||||
|
if (!merged.has(identity)) merged.set(identity, archived)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Array.from(merged.values()).sort((left, right) => {
|
||||||
|
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||||
|
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||||
|
})
|
||||||
|
const visible = limit && result.length > limit ? result.slice(-limit) : result
|
||||||
|
return visible as T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractChangedConversationMd5(payload: string): string[] {
|
||||||
|
const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || []
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
matches
|
||||||
|
.map((value) => value.replace(/^chat_/i, '').toLowerCase())
|
||||||
|
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveContact = {
|
||||||
|
md5: string
|
||||||
|
m_nsUsrName: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
activityKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecallArchiveMonitor {
|
||||||
|
private pending = new Set<string>()
|
||||||
|
private activityBySession = new Map<string, string>()
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private stopped = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly listContacts: () => ArchiveContact[],
|
||||||
|
private readonly loadMessages: (sessionMd5: string) => Message[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
seedAll(): void {
|
||||||
|
const groups = this.conversationContacts()
|
||||||
|
this.activityBySession = new Map(
|
||||||
|
groups.map((contact) => [contact.md5, String(contact.activityKey || '')])
|
||||||
|
)
|
||||||
|
const allowed = new Set(groups.map((contact) => contact.md5))
|
||||||
|
if (archive) {
|
||||||
|
let pruned = false
|
||||||
|
for (const sessionMd5 of Object.keys(archive.sessions)) {
|
||||||
|
if (allowed.has(sessionMd5)) continue
|
||||||
|
delete archive.sessions[sessionMd5]
|
||||||
|
pruned = true
|
||||||
|
}
|
||||||
|
if (pruned) {
|
||||||
|
archive.updatedAt = Date.now()
|
||||||
|
scheduleWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDatabaseChange(payload: string): void {
|
||||||
|
// Ordinary message inserts are already visible in the main reader. The
|
||||||
|
// archive scan is only needed when the native event indicates a recall.
|
||||||
|
if (!/(recall|revoke|revokemsg|撤回)/i.test(String(payload || ''))) return
|
||||||
|
const groups = this.conversationContacts()
|
||||||
|
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
|
||||||
|
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
|
||||||
|
const changedActivity = groups
|
||||||
|
.filter((contact) => {
|
||||||
|
const nextKey = String(contact.activityKey || '')
|
||||||
|
const previousKey = this.activityBySession.get(contact.md5)
|
||||||
|
this.activityBySession.set(contact.md5, nextKey)
|
||||||
|
return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey)
|
||||||
|
})
|
||||||
|
.map((contact) => contact.md5)
|
||||||
|
const targets = Array.from(new Set([...payloadTargets, ...changedActivity]))
|
||||||
|
if (targets.length > 0) {
|
||||||
|
console.log(`[RecallArchive] database change queued=${targets.length}`)
|
||||||
|
this.enqueue(targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private conversationContacts(): ArchiveContact[] {
|
||||||
|
return this.listContacts().filter(
|
||||||
|
(contact) =>
|
||||||
|
contact.m_nsUsrName &&
|
||||||
|
!contact.m_nsUsrName.startsWith('@placeholder') &&
|
||||||
|
contact.m_nsUsrName !== 'brandsessionholder' &&
|
||||||
|
contact.m_nsUsrName !== 'brandservicesessionholder'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true
|
||||||
|
this.pending.clear()
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(sessionMd5s: string[]): void {
|
||||||
|
if (this.stopped) return
|
||||||
|
for (const md5 of sessionMd5s) {
|
||||||
|
if (md5) this.pending.add(md5)
|
||||||
|
}
|
||||||
|
this.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(): void {
|
||||||
|
if (this.stopped || this.timer || this.pending.size === 0) return
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null
|
||||||
|
const md5 = this.pending.values().next().value as string | undefined
|
||||||
|
if (!md5) return
|
||||||
|
this.pending.delete(md5)
|
||||||
|
try {
|
||||||
|
this.loadMessages(md5)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error)
|
||||||
|
}
|
||||||
|
this.schedule()
|
||||||
|
}, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,22 @@ import fs from 'fs-extra'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
|
||||||
|
* "...\\Documents\\xwechat_files"(V4)。如果 xwechat_files 不存在则保留原值。
|
||||||
|
* 仅支持 WeChat 4.0:自动纠正用户机器上残留的旧路径。
|
||||||
|
*/
|
||||||
|
function redirectLegacyWeChatFilesToXwechat(candidate: string): string {
|
||||||
|
if (!candidate) return candidate
|
||||||
|
const normalized = candidate.replace(/[\\/]+$/, '')
|
||||||
|
const lowered = normalized.toLowerCase()
|
||||||
|
const legacyMarker = `${path.sep}wechat files`
|
||||||
|
if (!lowered.endsWith(legacyMarker)) return candidate
|
||||||
|
const redirected = `${normalized.slice(0, -legacyMarker.length)}${path.sep}xwechat_files`
|
||||||
|
if (fs.existsSync(redirected)) return redirected
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
dbRoot: string
|
dbRoot: string
|
||||||
apiEnabled: boolean
|
apiEnabled: boolean
|
||||||
@@ -12,6 +28,13 @@ export interface AppSettings {
|
|||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
imageKeyFallbackDisabled: boolean
|
imageKeyFallbackDisabled: boolean
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDbRoot(): string {
|
function getDefaultDbRoot(): string {
|
||||||
@@ -22,28 +45,20 @@ function getDefaultDbRoot(): string {
|
|||||||
|
|
||||||
function getDefaultDbRootCandidates(home: string): string[] {
|
function getDefaultDbRootCandidates(home: string): string[] {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
|
// macOS 仅支持 WeChat 4.0 路径(xwechat_files)
|
||||||
return [
|
return [
|
||||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅支持 WeChat 4.0:剔除 V3 时代的 "WeChat Files" 目录,
|
||||||
|
// 只认 xwechat_files(含 Documents\ 和 AppData\Roaming\Tencent\ 两种合法位置)。
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...getWeflowDbPathCandidates(home),
|
...getWeflowDbPathCandidates(home),
|
||||||
path.join(home, 'Documents', 'WeChat Files'),
|
|
||||||
path.join(home, 'Documents', 'xwechat_files'),
|
path.join(home, 'Documents', 'xwechat_files'),
|
||||||
path.join(home, 'WeChat Files'),
|
|
||||||
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const drive of getWindowsDrives()) {
|
|
||||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
|
||||||
for (const child of listDirectories(`${drive}:\\`)) {
|
|
||||||
candidates.push(path.join(child, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(child, 'WeChat Files'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return unique(candidates)
|
return unique(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,32 +81,6 @@ function getWeflowDbPathCandidates(home: string): string[] {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWindowsDrives(): string[] {
|
|
||||||
const drives: string[] = []
|
|
||||||
for (let code = 67; code <= 90; code += 1) {
|
|
||||||
const drive = String.fromCharCode(code)
|
|
||||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
|
||||||
}
|
|
||||||
return drives
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDirectories(root: string): string[] {
|
|
||||||
try {
|
|
||||||
return fs
|
|
||||||
.readdirSync(root)
|
|
||||||
.map((name) => path.join(root, name))
|
|
||||||
.filter((candidate) => {
|
|
||||||
try {
|
|
||||||
return fs.statSync(candidate).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unique(values: string[]): string[] {
|
function unique(values: string[]): string[] {
|
||||||
return Array.from(new Set(values))
|
return Array.from(new Set(values))
|
||||||
}
|
}
|
||||||
@@ -118,7 +107,18 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
imageKeyRoot: defaultDbRoot,
|
imageKeyRoot: defaultDbRoot,
|
||||||
imageXorKey: '',
|
imageXorKey: '',
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
imageKeyFallbackDisabled: false
|
imageKeyFallbackDisabled: false,
|
||||||
|
recallProtectionEnabled: false,
|
||||||
|
debugEnabled: false,
|
||||||
|
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||||
|
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
),
|
||||||
|
autoLoginPreferenceSet: false,
|
||||||
|
appearanceTheme: 'system',
|
||||||
|
compactMode: false,
|
||||||
|
showStartupProgress: true
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_FILE = path.join(
|
const SETTINGS_FILE = path.join(
|
||||||
@@ -138,12 +138,28 @@ export function loadSettings(): AppSettings {
|
|||||||
if (fs.existsSync(SETTINGS_FILE)) {
|
if (fs.existsSync(SETTINGS_FILE)) {
|
||||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||||
|
if (raw.autoLogin === undefined) {
|
||||||
|
const hasSavedDatabaseKey = fs.existsSync(
|
||||||
|
path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||||
|
)
|
||||||
|
if (hasSavedDatabaseKey) cache.autoLogin = true
|
||||||
|
}
|
||||||
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
||||||
cache.dbRoot = getDefaultDbRoot()
|
cache.dbRoot = getDefaultDbRoot()
|
||||||
}
|
}
|
||||||
if (!cache.imageKeyRoot) {
|
// 同步:imageKeyRoot 必须跟随 dbRoot 更新,
|
||||||
|
// 否则自动获取会扫错目录(旧 bug:状态面板显示 D 盘,自动获取扫 C 盘)。
|
||||||
|
if (!cache.imageKeyRoot || !isUsableDbRoot(cache.imageKeyRoot)) {
|
||||||
cache.imageKeyRoot = cache.dbRoot
|
cache.imageKeyRoot = cache.dbRoot
|
||||||
}
|
}
|
||||||
|
// V4-only 兜底:如果 imageKeyRoot 指向旧的 "WeChat Files"(V3 路径),
|
||||||
|
// 重定向到同一父目录下的 xwechat_files(V4)。
|
||||||
|
if (cache.imageKeyRoot) {
|
||||||
|
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
|
||||||
|
}
|
||||||
|
if (cache.dbRoot) {
|
||||||
|
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
|
||||||
|
}
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app, shell } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
import { existsSync, promises as fs } from 'fs'
|
import { existsSync, promises as fs } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
|
||||||
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||||
const GITHUB_URL =
|
const GITHUB_URL =
|
||||||
@@ -23,7 +24,7 @@ function getSkillCandidates(): { path: string; source: 'development' | 'bundled'
|
|||||||
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
|
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
|
||||||
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
|
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
|
||||||
]
|
]
|
||||||
return app.isPackaged
|
return isPackagedRuntime()
|
||||||
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
|
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
|
||||||
: [
|
: [
|
||||||
{ path: developmentPath, source: 'development' as const },
|
{ path: developmentPath, source: 'development' as const },
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ export class StickerService {
|
|||||||
const normalizedMd5 = this.normalizeMd5(md5)
|
const normalizedMd5 = this.normalizeMd5(md5)
|
||||||
let url = String(cdnUrl || '').trim()
|
let url = String(cdnUrl || '').trim()
|
||||||
|
|
||||||
|
const cacheKey =
|
||||||
|
normalizedMd5 || (url ? crypto.createHash('md5').update(url).digest('hex') : '')
|
||||||
|
if (cacheKey) {
|
||||||
|
const cached = await this.readCached(cacheKey)
|
||||||
|
if (cached) return { success: true, data: cached }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMd5 && this.wcdb4Client) {
|
||||||
|
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
|
||||||
|
if (wechatCached) return { success: true, data: wechatCached }
|
||||||
|
}
|
||||||
|
|
||||||
if (!url && normalizedMd5 && this.wcdb4Client) {
|
if (!url && normalizedMd5 && this.wcdb4Client) {
|
||||||
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
|
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -32,30 +44,26 @@ export class StickerService {
|
|||||||
return { success: false, error: '未找到表情包 CDN URL' }
|
return { success: false, error: '未找到表情包 CDN URL' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = normalizedMd5 || crypto.createHash('md5').update(url).digest('hex')
|
const resolvedCacheKey = cacheKey || crypto.createHash('md5').update(url).digest('hex')
|
||||||
const cached = await this.readCached(cacheKey)
|
|
||||||
if (cached) return { success: true, data: cached }
|
|
||||||
|
|
||||||
if (normalizedMd5 && this.wcdb4Client) {
|
const pending = downloadCache.get(resolvedCacheKey)
|
||||||
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
|
|
||||||
if (wechatCached) return { success: true, data: wechatCached }
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = downloadCache.get(cacheKey)
|
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
const task = this.downloadToDataUrl(url, cacheKey)
|
const task = this.downloadToDataUrl(url, resolvedCacheKey)
|
||||||
downloadCache.set(cacheKey, task)
|
downloadCache.set(resolvedCacheKey, task)
|
||||||
try {
|
try {
|
||||||
return await task
|
return await task
|
||||||
} finally {
|
} finally {
|
||||||
downloadCache.delete(cacheKey)
|
downloadCache.delete(resolvedCacheKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readCached(cacheKey: string): Promise<string | null> {
|
private async readCached(cacheKey: string): Promise<string | null> {
|
||||||
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||||
const cacheDirs = [this.cacheDir, path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')]
|
const cacheDirs = [
|
||||||
|
this.cacheDir,
|
||||||
|
path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
||||||
|
]
|
||||||
for (const cacheDir of cacheDirs) {
|
for (const cacheDir of cacheDirs) {
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import type { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
|
type VideoAsset = {
|
||||||
|
filePath: string
|
||||||
|
posterPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoAssetService {
|
||||||
|
private readonly urlTokens = new Map<string, string>()
|
||||||
|
private index: Map<string, VideoAsset> | null = null
|
||||||
|
|
||||||
|
constructor(private readonly client: Wcdb4Client) {}
|
||||||
|
|
||||||
|
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
|
||||||
|
const candidates = Array.from(
|
||||||
|
new Set(
|
||||||
|
hashes
|
||||||
|
.map((value) =>
|
||||||
|
String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
|
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (candidates.length === 0) return { success: false, error: '视频标识为空' }
|
||||||
|
|
||||||
|
const hardlinkDb = path.join(
|
||||||
|
this.client.getAccountRoot(),
|
||||||
|
'db_storage',
|
||||||
|
'hardlink',
|
||||||
|
'hardlink.db'
|
||||||
|
)
|
||||||
|
const lookupKeys = [...candidates]
|
||||||
|
if (fs.existsSync(hardlinkDb)) {
|
||||||
|
for (const hash of candidates) {
|
||||||
|
const resolved = this.client.resolveVideoHardlink(hash, hardlinkDb)?.resolved_md5
|
||||||
|
if (resolved) lookupKeys.unshift(String(resolved).trim().toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.getIndex()
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
const asset = index.get(key) || index.get(`${key}_raw`)
|
||||||
|
if (!asset) continue
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: this.createUrl(asset.filePath),
|
||||||
|
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: '本地未找到该视频文件' }
|
||||||
|
}
|
||||||
|
|
||||||
|
pathForToken(token: string): string | undefined {
|
||||||
|
const filePath = this.urlTokens.get(token)
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) return undefined
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUrl(filePath: string): string {
|
||||||
|
const token = crypto.randomBytes(18).toString('hex')
|
||||||
|
this.urlTokens.set(token, filePath)
|
||||||
|
if (this.urlTokens.size > 500) {
|
||||||
|
const first = this.urlTokens.keys().next().value
|
||||||
|
if (first) this.urlTokens.delete(first)
|
||||||
|
}
|
||||||
|
return `wxe-media://local/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIndex(): Map<string, VideoAsset> {
|
||||||
|
if (this.index) return this.index
|
||||||
|
const result = new Map<string, VideoAsset>()
|
||||||
|
const root = path.join(this.client.getAccountRoot(), 'msg', 'video')
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
this.index = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const month of fs.readdirSync(root)) {
|
||||||
|
const monthPath = path.join(root, month)
|
||||||
|
if (!fs.statSync(monthPath).isDirectory()) continue
|
||||||
|
for (const name of fs.readdirSync(monthPath)) {
|
||||||
|
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
|
||||||
|
if (!match) continue
|
||||||
|
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
||||||
|
const fullPath = path.join(monthPath, name)
|
||||||
|
const existing = result.get(key) || { filePath: '' }
|
||||||
|
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
|
||||||
|
else if (!existing.posterPath) existing.posterPath = fullPath
|
||||||
|
result.set(key, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, asset] of result) {
|
||||||
|
if (!asset.filePath) result.delete(key)
|
||||||
|
}
|
||||||
|
this.index = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
|
import { isPackagedRuntime } from './runtime-mode'
|
||||||
|
|
||||||
export class VoiceService {
|
export class VoiceService {
|
||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
@@ -101,7 +102,7 @@ export class VoiceService {
|
|||||||
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
let wasmPath: string
|
let wasmPath: string
|
||||||
if (app.isPackaged) {
|
if (isPackagedRuntime()) {
|
||||||
wasmPath = join(
|
wasmPath = join(
|
||||||
process.resourcesPath,
|
process.resourcesPath,
|
||||||
'app.asar.unpacked',
|
'app.asar.unpacked',
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ export interface Wcdb4Session {
|
|||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
wechatNickname?: string
|
||||||
|
remark?: string
|
||||||
raw: Record<string, unknown>
|
raw: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Wcdb4Message {
|
export interface Wcdb4Message {
|
||||||
mesLocalID: string
|
mesLocalID: string
|
||||||
|
serverId?: string
|
||||||
mesDes: number
|
mesDes: number
|
||||||
messageType: string
|
messageType: string
|
||||||
msgCreateTime: string
|
msgCreateTime: string
|
||||||
@@ -29,9 +32,17 @@ export interface Wcdb4MessageQueryOptions {
|
|||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Wcdb4MessageStore = {
|
||||||
|
tableName: string
|
||||||
|
dbPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Wcdb4GroupMember {
|
export interface Wcdb4GroupMember {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
m_nsHeadImgUrl: string
|
m_nsHeadImgUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +52,11 @@ export interface Wcdb4ImageHardlink {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Wcdb4VideoHardlink {
|
||||||
|
resolved_md5?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
type KoffiModule = {
|
type KoffiModule = {
|
||||||
load: (libraryPath: string) => KoffiLibrary
|
load: (libraryPath: string) => KoffiLibrary
|
||||||
decode: (ptr: unknown, type: string, length: number) => string
|
decode: (ptr: unknown, type: string, length: number) => string
|
||||||
@@ -52,6 +68,8 @@ type KoffiModule = {
|
|||||||
// library reference for every Wcdb4Client instance.
|
// library reference for every Wcdb4Client instance.
|
||||||
let wcdbBootstrapLib: KoffiLibrary | null = null
|
let wcdbBootstrapLib: KoffiLibrary | null = null
|
||||||
|
|
||||||
|
let wcdbBootstrapAsyncPromise: Promise<KoffiLibrary> | null = null
|
||||||
|
|
||||||
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
||||||
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
||||||
|
|
||||||
@@ -125,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string):
|
|||||||
return lib
|
return lib
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bootstrapWcdbNativeAsync(
|
||||||
|
libPath?: string,
|
||||||
|
libDirOverride?: string
|
||||||
|
): Promise<KoffiLibrary> {
|
||||||
|
if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib)
|
||||||
|
if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise
|
||||||
|
|
||||||
|
wcdbBootstrapAsyncPromise = (async () => {
|
||||||
|
const koffi = nodeRequire('koffi') as KoffiModule
|
||||||
|
const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary()
|
||||||
|
const libDir = libDirOverride || path.dirname(resolvedLibPath)
|
||||||
|
for (const name of process.platform === 'win32'
|
||||||
|
? ['WCDB.dll', 'SDL2.dll']
|
||||||
|
: process.platform === 'darwin'
|
||||||
|
? ['libWCDB.dylib']
|
||||||
|
: []) {
|
||||||
|
const preloadPath = path.join(libDir, name)
|
||||||
|
if (!fs.existsSync(preloadPath)) continue
|
||||||
|
try {
|
||||||
|
koffi.load(preloadPath)
|
||||||
|
} catch {
|
||||||
|
// The main library may still resolve its dependencies through the loader.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lib = koffi.load(resolvedLibPath)
|
||||||
|
const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as (
|
||||||
|
resourcePath: string
|
||||||
|
) => number
|
||||||
|
const resourceRoots = Array.from(
|
||||||
|
new Set(
|
||||||
|
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let initOk = false
|
||||||
|
for (const resourceRoot of resourceRoots) {
|
||||||
|
try {
|
||||||
|
if (Number(initProtection(resourceRoot)) === 0) {
|
||||||
|
initOk = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next resource root.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (initOk) {
|
||||||
|
const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
wcdbInit.async((error: unknown, result: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Number(result) !== 0) {
|
||||||
|
console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wcdbBootstrapLib = lib
|
||||||
|
return lib
|
||||||
|
})().catch((error) => {
|
||||||
|
wcdbBootstrapAsyncPromise = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
return wcdbBootstrapAsyncPromise
|
||||||
|
}
|
||||||
|
|
||||||
type KoffiLibrary = {
|
type KoffiLibrary = {
|
||||||
func: (signature: string) => (...args: unknown[]) => unknown
|
func: (signature: string) => KoffiAsyncFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
|
||||||
|
async: (...args: unknown[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type WcdbVoidOut = [unknown]
|
type WcdbVoidOut = [unknown]
|
||||||
@@ -154,6 +246,7 @@ export class Wcdb4Client {
|
|||||||
private wcdbOpenAccount:
|
private wcdbOpenAccount:
|
||||||
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null
|
||||||
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
||||||
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
||||||
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
||||||
@@ -213,6 +306,9 @@ export class Wcdb4Client {
|
|||||||
private wcdbResolveImageHardlink:
|
private wcdbResolveImageHardlink:
|
||||||
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbResolveVideoHardlink:
|
||||||
|
| ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number)
|
||||||
|
| null = null
|
||||||
private wcdbGetEmoticonCdnUrl:
|
private wcdbGetEmoticonCdnUrl:
|
||||||
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
@@ -293,24 +389,17 @@ export class Wcdb4Client {
|
|||||||
private static getDefaultRootCandidates(): string[] {
|
private static getDefaultRootCandidates(): string[] {
|
||||||
const home = os.homedir()
|
const home = os.homedir()
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
|
// 仅支持 WeChat 4.0:只认 xwechat_files,V3 时代的 "WeChat Files" 不再加入候选
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...Wcdb4Client.getWeflowDbPathCandidates(home),
|
...Wcdb4Client.getWeflowDbPathCandidates(home),
|
||||||
path.join(home, 'Documents', 'WeChat Files'),
|
|
||||||
path.join(home, 'Documents', 'xwechat_files'),
|
path.join(home, 'Documents', 'xwechat_files'),
|
||||||
path.join(home, 'WeChat Files'),
|
|
||||||
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||||
]
|
]
|
||||||
for (const drive of Wcdb4Client.getWindowsDrives()) {
|
|
||||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
|
||||||
for (const child of Wcdb4Client.listDirectories(`${drive}:\\`)) {
|
|
||||||
candidates.push(path.join(child, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(child, 'WeChat Files'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(new Set(candidates))
|
return Array.from(new Set(candidates))
|
||||||
}
|
}
|
||||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
return [
|
||||||
|
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getWeflowDbPathCandidates(home: string): string[] {
|
private static getWeflowDbPathCandidates(home: string): string[] {
|
||||||
@@ -332,35 +421,11 @@ export class Wcdb4Client {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getWindowsDrives(): string[] {
|
|
||||||
const drives: string[] = []
|
|
||||||
for (let code = 67; code <= 90; code += 1) {
|
|
||||||
const drive = String.fromCharCode(code)
|
|
||||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
|
||||||
}
|
|
||||||
return drives
|
|
||||||
}
|
|
||||||
|
|
||||||
private static listDirectories(root: string): string[] {
|
|
||||||
try {
|
|
||||||
return fs
|
|
||||||
.readdirSync(root)
|
|
||||||
.map((name) => path.join(root, name))
|
|
||||||
.filter((candidate) => {
|
|
||||||
try {
|
|
||||||
return fs.statSync(candidate).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static hasDbStorage(candidate: string): boolean {
|
private static hasDbStorage(candidate: string): boolean {
|
||||||
try {
|
try {
|
||||||
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
return (
|
||||||
|
fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -440,6 +505,39 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openAsync(): Promise<void> {
|
||||||
|
this.loadNativeLibrary()
|
||||||
|
if (!this.wcdbOpenAccountAsync) {
|
||||||
|
throw new Error('WCDB 4.0 native open async interface unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOut: WcdbHandleOut = [0]
|
||||||
|
const openResult = await new Promise<number>((resolve, reject) => {
|
||||||
|
this.wcdbOpenAccountAsync!.async(
|
||||||
|
this.sessionDbPath,
|
||||||
|
this.key,
|
||||||
|
handleOut,
|
||||||
|
(error: unknown, result: unknown) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve(Number(result))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.handle = handleOut[0]
|
||||||
|
if (this.wcdbSetMyWxid) {
|
||||||
|
try {
|
||||||
|
this.wcdbSetMyWxid(this.handle, this.wxid)
|
||||||
|
} catch {
|
||||||
|
// Optional helper. Failure does not block message reads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.stopMonitor()
|
this.stopMonitor()
|
||||||
if (this.handle === null || !this.wcdbShutdown) return
|
if (this.handle === null || !this.wcdbShutdown) return
|
||||||
@@ -617,6 +715,11 @@ export class Wcdb4Client {
|
|||||||
return this.cachedSessions
|
return this.cachedSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateSessionCache(): void {
|
||||||
|
this.cachedSessions = null
|
||||||
|
this.cachedChatTables = null
|
||||||
|
}
|
||||||
|
|
||||||
getChatTables(): { name: string; db_number: string }[] {
|
getChatTables(): { name: string; db_number: string }[] {
|
||||||
if (this.cachedChatTables) return this.cachedChatTables
|
if (this.cachedChatTables) return this.cachedChatTables
|
||||||
const sessions =
|
const sessions =
|
||||||
@@ -645,10 +748,12 @@ export class Wcdb4Client {
|
|||||||
try {
|
try {
|
||||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
||||||
if (cursorMessages) {
|
if (cursorMessages) {
|
||||||
|
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
|
||||||
|
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
|
||||||
console.log(
|
console.log(
|
||||||
`[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms`
|
`[WCDB4] getMessages cursor ok username=${username} rows=${mergedMessages.length} recovered=${recoveredMessages.length} cost=${Date.now() - startedAt}ms`
|
||||||
)
|
)
|
||||||
return cursorMessages
|
return mergedMessages
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
||||||
@@ -677,10 +782,7 @@ export class Wcdb4Client {
|
|||||||
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error)
|
||||||
`[WCDB4] get_messages failed username=${username} offset=${offset}:`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const batch = Array.isArray(rows) ? rows : []
|
const batch = Array.isArray(rows) ? rows : []
|
||||||
@@ -707,6 +809,21 @@ export class Wcdb4Client {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessagesAsync(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options: Wcdb4MessageQueryOptions = {}
|
||||||
|
): Promise<Wcdb4Message[]> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||||
|
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||||
|
console.log(
|
||||||
|
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
private readSessionRows(): Record<string, unknown>[] {
|
private readSessionRows(): Record<string, unknown>[] {
|
||||||
if (!this.wcdbGetSessions) return []
|
if (!this.wcdbGetSessions) return []
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
@@ -723,17 +840,9 @@ export class Wcdb4Client {
|
|||||||
): Wcdb4Message[] {
|
): Wcdb4Message[] {
|
||||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||||
|
|
||||||
let tables: { tableName: string; dbPath: string }[] = []
|
let tables: Wcdb4MessageStore[] = []
|
||||||
try {
|
try {
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
tables = this.listMessageStores(username)
|
||||||
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
|
||||||
)
|
|
||||||
tables = (Array.isArray(rows) ? rows : [])
|
|
||||||
.map((row) => ({
|
|
||||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
|
||||||
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
|
||||||
}))
|
|
||||||
.filter((row) => row.tableName && row.dbPath)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
||||||
return []
|
return []
|
||||||
@@ -768,6 +877,172 @@ export class Wcdb4Client {
|
|||||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
|
||||||
|
const stores = new Map<string, Wcdb4MessageStore>()
|
||||||
|
for (const username of this.uniq(usernames)) {
|
||||||
|
try {
|
||||||
|
for (const store of this.listMessageStores(username)) {
|
||||||
|
stores.set(`${store.dbPath}\u0000${store.tableName}`, store)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = 0
|
||||||
|
let failed = 0
|
||||||
|
for (const store of stores.values()) {
|
||||||
|
try {
|
||||||
|
const columns = this.readMessageColumns(store)
|
||||||
|
if (columns.length === 0) throw new Error('消息表没有可归档列')
|
||||||
|
this.ensureRecallJournalTable(store, columns)
|
||||||
|
this.createRecallJournalTrigger(store, columns)
|
||||||
|
installed += 1
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1
|
||||||
|
console.warn(
|
||||||
|
`[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { installed, failed }
|
||||||
|
}
|
||||||
|
|
||||||
|
private listMessageStores(username: string): Wcdb4MessageStore[] {
|
||||||
|
if (!this.wcdbGetMessageTableStats) return []
|
||||||
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
|
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
||||||
|
)
|
||||||
|
return (Array.isArray(rows) ? rows : [])
|
||||||
|
.map((row) => ({
|
||||||
|
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||||
|
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
||||||
|
}))
|
||||||
|
.filter((row) => row.tableName && row.dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
|
||||||
|
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
|
||||||
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
|
this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson)
|
||||||
|
)
|
||||||
|
return Array.isArray(rows) ? rows : []
|
||||||
|
}
|
||||||
|
|
||||||
|
private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] {
|
||||||
|
const rows = this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})`
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const name = this.pickString(row, ['name'])
|
||||||
|
const type = this.pickString(row, ['type']).toUpperCase()
|
||||||
|
const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB'
|
||||||
|
return { name, declaration }
|
||||||
|
})
|
||||||
|
.filter((column) => column.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureRecallJournalTable(
|
||||||
|
store: Wcdb4MessageStore,
|
||||||
|
columns: { name: string; declaration: string }[]
|
||||||
|
): void {
|
||||||
|
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||||
|
const definitions = columns
|
||||||
|
.map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`)
|
||||||
|
.join(', ')
|
||||||
|
this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRecallJournalTrigger(
|
||||||
|
store: Wcdb4MessageStore,
|
||||||
|
columns: { name: string; declaration: string }[]
|
||||||
|
): void {
|
||||||
|
const table = this.quoteSqlIdentifier(store.tableName)
|
||||||
|
const triggerName = this.quoteSqlIdentifier(
|
||||||
|
`_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}`
|
||||||
|
)
|
||||||
|
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||||
|
const targetColumns = [
|
||||||
|
...columns.map((column) => this.quoteSqlIdentifier(column.name)),
|
||||||
|
'"_wxe_source_table"',
|
||||||
|
'"_wxe_captured_at"'
|
||||||
|
].join(', ')
|
||||||
|
const sourceValues = [
|
||||||
|
...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`),
|
||||||
|
`'${store.tableName.replace(/'/g, "''")}'`,
|
||||||
|
`CAST(strftime('%s', 'now') AS INTEGER)`
|
||||||
|
].join(', ')
|
||||||
|
this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRecallJournal(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): Wcdb4Message[] {
|
||||||
|
const recoveredRows: Record<string, unknown>[] = []
|
||||||
|
for (const store of this.listMessageStores(username)) {
|
||||||
|
try {
|
||||||
|
const begin = this.normalizeTimestamp(startTime || 0)
|
||||||
|
const end = this.normalizeTimestamp(endTime || 0)
|
||||||
|
const where = [
|
||||||
|
`"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`,
|
||||||
|
begin > 0 ? `"create_time" >= ${begin}` : '',
|
||||||
|
end > 0 ? `"create_time" <= ${end}` : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
const rows = this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500`
|
||||||
|
)
|
||||||
|
recoveredRows.push(...rows)
|
||||||
|
} catch {
|
||||||
|
// The journal is optional until installation has completed for this store.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeMessages(username, recoveredRows, startTime, endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeMessageRows(
|
||||||
|
current: Wcdb4Message[],
|
||||||
|
recovered: Wcdb4Message[],
|
||||||
|
limit?: number
|
||||||
|
): Wcdb4Message[] {
|
||||||
|
const merged = new Map<string, Wcdb4Message>()
|
||||||
|
for (const message of [...recovered, ...current]) {
|
||||||
|
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
||||||
|
const identity = message.mesLocalID
|
||||||
|
? `local:${message.mesLocalID}`
|
||||||
|
: message.serverId
|
||||||
|
? `server:${message.serverId}`
|
||||||
|
: `${message.msgCreateTime}:${message.msgContent}`
|
||||||
|
const key = recoveredRow ? `recovered:${identity}` : identity
|
||||||
|
merged.set(key, message)
|
||||||
|
}
|
||||||
|
const messages = Array.from(merged.values()).sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
|
||||||
|
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
|
||||||
|
)
|
||||||
|
return limit && messages.length > limit ? messages.slice(-limit) : messages
|
||||||
|
}
|
||||||
|
|
||||||
|
private recallJournalTableName(messageTableName: string): string {
|
||||||
|
return `_wxe_recall_journal_${crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(messageTableName)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)}`
|
||||||
|
}
|
||||||
|
|
||||||
getMyAvatarUrl(): string | undefined {
|
getMyAvatarUrl(): string | undefined {
|
||||||
const candidates = this.getMyUsernameCandidates()
|
const candidates = this.getMyUsernameCandidates()
|
||||||
this.hydrateAvatarUrls(candidates)
|
this.hydrateAvatarUrls(candidates)
|
||||||
@@ -885,26 +1160,25 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
||||||
|
|
||||||
return visibleMessages
|
return visibleMessages.map((message) => {
|
||||||
.map((message) => {
|
if (!message.sender) return message
|
||||||
if (!message.sender) return message
|
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
||||||
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
||||||
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
const shouldPrefixSender =
|
||||||
const shouldPrefixSender =
|
username.endsWith('@chatroom') &&
|
||||||
username.endsWith('@chatroom') &&
|
message.mesDes === 1 &&
|
||||||
message.mesDes === 1 &&
|
message.sender &&
|
||||||
message.sender &&
|
message.msgContent &&
|
||||||
message.msgContent &&
|
!message.msgContent.startsWith(`${message.sender}:`)
|
||||||
!message.msgContent.startsWith(`${message.sender}:`)
|
return {
|
||||||
return {
|
...message,
|
||||||
...message,
|
senderNickname,
|
||||||
senderNickname,
|
senderAvatar,
|
||||||
senderAvatar,
|
msgContent: shouldPrefixSender
|
||||||
msgContent: shouldPrefixSender
|
? `${message.sender}:\n${message.msgContent}`
|
||||||
? `${message.sender}:\n${message.msgContent}`
|
: message.msgContent
|
||||||
: message.msgContent
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMessageLimit(limit?: number): number | undefined {
|
private normalizeMessageLimit(limit?: number): number | undefined {
|
||||||
@@ -930,18 +1204,21 @@ export class Wcdb4Client {
|
|||||||
'member_username',
|
'member_username',
|
||||||
'm_nsUsrName'
|
'm_nsUsrName'
|
||||||
])
|
])
|
||||||
const memberNickname = this.pickString(row, [
|
const wechatNickname = this.pickString(row, [
|
||||||
'nickname',
|
'nickname',
|
||||||
'nickName',
|
'nickName',
|
||||||
'displayName',
|
'wechatNickname',
|
||||||
'display_name',
|
'wechat_nickname',
|
||||||
'groupNickname',
|
|
||||||
'group_nickname',
|
|
||||||
'roomNickname',
|
|
||||||
'room_nickname',
|
|
||||||
'remark',
|
|
||||||
'm_nsNickName'
|
'm_nsNickName'
|
||||||
])
|
])
|
||||||
|
const remark = this.pickString(row, [
|
||||||
|
'remark',
|
||||||
|
'remarkName',
|
||||||
|
'remark_name',
|
||||||
|
'contactRemark',
|
||||||
|
'contact_remark'
|
||||||
|
])
|
||||||
|
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||||
const avatar = this.pickString(row, [
|
const avatar = this.pickString(row, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'avatar_url',
|
'avatar_url',
|
||||||
@@ -955,7 +1232,10 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
m_nsUsrName: username,
|
||||||
nickname: groupNicknames.get(username) || memberNickname,
|
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||||
|
groupNickname: groupNicknames.get(username) || '',
|
||||||
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
|
remark,
|
||||||
m_nsHeadImgUrl: avatar
|
m_nsHeadImgUrl: avatar
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -975,6 +1255,8 @@ export class Wcdb4Client {
|
|||||||
...member,
|
...member,
|
||||||
nickname:
|
nickname:
|
||||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||||
|
wechatNickname:
|
||||||
|
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -982,6 +1264,140 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getMessagesByCursorAsync(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
|
): Promise<Wcdb4Message[]> {
|
||||||
|
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
|
||||||
|
|
||||||
|
const handle = this.ensureHandle()
|
||||||
|
const batchSize = limit ? Math.min(500, limit) : 1000
|
||||||
|
const cursorOut: WcdbHandleOut = [0]
|
||||||
|
const begin = this.normalizeTimestamp(startTime || 0)
|
||||||
|
const end = this.normalizeTimestamp(endTime || 0)
|
||||||
|
const ascending = limit ? 0 : 1
|
||||||
|
const openResult = await this.callAsyncCode(
|
||||||
|
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
|
||||||
|
handle,
|
||||||
|
username,
|
||||||
|
batchSize,
|
||||||
|
ascending,
|
||||||
|
begin,
|
||||||
|
end,
|
||||||
|
cursorOut
|
||||||
|
)
|
||||||
|
if (openResult !== 0 || cursorOut[0] <= 0) return []
|
||||||
|
|
||||||
|
const cursor = cursorOut[0]
|
||||||
|
const allRows: Record<string, unknown>[] = []
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const outJson: WcdbVoidOut = [null]
|
||||||
|
const outHasMore: [number] = [0]
|
||||||
|
const fetchResult = await this.callAsyncCode(
|
||||||
|
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
|
||||||
|
handle,
|
||||||
|
cursor,
|
||||||
|
outJson,
|
||||||
|
outHasMore
|
||||||
|
)
|
||||||
|
if (fetchResult !== 0 || !outJson[0]) break
|
||||||
|
try {
|
||||||
|
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||||
|
const batch = JSON.parse(json) as Record<string, unknown>[]
|
||||||
|
if (Array.isArray(batch)) allRows.push(...batch)
|
||||||
|
} finally {
|
||||||
|
this.wcdbFreeString?.(outJson[0])
|
||||||
|
}
|
||||||
|
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
this.wcdbCloseMessageCursor?.(handle, cursor)
|
||||||
|
} catch {
|
||||||
|
// Best-effort cursor cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembersAsync(chatroomId: string): Promise<Wcdb4GroupMember[]> {
|
||||||
|
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
|
||||||
|
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||||
|
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
||||||
|
chatroomId
|
||||||
|
)
|
||||||
|
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||||
|
const username = this.pickString(row, [
|
||||||
|
'username',
|
||||||
|
'userName',
|
||||||
|
'user_name',
|
||||||
|
'member_username',
|
||||||
|
'm_nsUsrName'
|
||||||
|
])
|
||||||
|
const wechatNickname = this.pickString(row, [
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'wechatNickname',
|
||||||
|
'wechat_nickname',
|
||||||
|
'm_nsNickName'
|
||||||
|
])
|
||||||
|
const remark = this.pickString(row, [
|
||||||
|
'remark',
|
||||||
|
'remarkName',
|
||||||
|
'remark_name',
|
||||||
|
'contactRemark',
|
||||||
|
'contact_remark'
|
||||||
|
])
|
||||||
|
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||||
|
const avatar = this.pickString(row, [
|
||||||
|
'avatarUrl',
|
||||||
|
'avatar_url',
|
||||||
|
'headImgUrl',
|
||||||
|
'm_nsHeadImgUrl'
|
||||||
|
])
|
||||||
|
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||||
|
return {
|
||||||
|
m_nsUsrName: username,
|
||||||
|
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||||
|
groupNickname: groupNicknames.get(username) || '',
|
||||||
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
|
remark,
|
||||||
|
m_nsHeadImgUrl: avatar
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const missingNames = members
|
||||||
|
.filter((member) => !member.nickname)
|
||||||
|
.map((member) => member.m_nsUsrName)
|
||||||
|
.filter(Boolean)
|
||||||
|
const missingAvatars = members
|
||||||
|
.filter((member) => !member.m_nsHeadImgUrl)
|
||||||
|
.map((member) => member.m_nsUsrName)
|
||||||
|
.filter(Boolean)
|
||||||
|
await Promise.all([
|
||||||
|
this.hydrateDisplayNamesAsync(missingNames),
|
||||||
|
this.hydrateAvatarUrlsAsync(missingAvatars)
|
||||||
|
])
|
||||||
|
return members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
nickname:
|
||||||
|
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||||
|
wechatNickname:
|
||||||
|
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||||
|
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||||
const cached = this.groupNicknameCache.get(chatroomId)
|
const cached = this.groupNicknameCache.get(chatroomId)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
@@ -1010,6 +1426,28 @@ export class Wcdb4Client {
|
|||||||
return nicknames
|
return nicknames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getGroupNicknamesAsync(chatroomId: string): Promise<Map<string, string>> {
|
||||||
|
const cached = this.groupNicknameCache.get(chatroomId)
|
||||||
|
if (cached) return cached
|
||||||
|
const nicknames = new Map<string, string>()
|
||||||
|
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||||
|
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'groupNickname',
|
||||||
|
'group_nickname',
|
||||||
|
'name'
|
||||||
|
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||||
|
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||||
|
return nicknames
|
||||||
|
}
|
||||||
|
|
||||||
async getVoiceData(
|
async getVoiceData(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
createTime: number,
|
createTime: number,
|
||||||
@@ -1089,6 +1527,23 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
|
||||||
|
if (!this.wcdbResolveVideoHardlink) return null
|
||||||
|
const normalizedMd5 = String(md5 || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return this.callJson<Wcdb4VideoHardlink>((handle, outJson) =>
|
||||||
|
this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson)
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] resolve video hardlink failed:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolveEmoticonCdnUrl(md5: string): string | undefined {
|
resolveEmoticonCdnUrl(md5: string): string | undefined {
|
||||||
if (!this.wcdbGetEmoticonCdnUrl) {
|
if (!this.wcdbGetEmoticonCdnUrl) {
|
||||||
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
||||||
@@ -1180,9 +1635,11 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
||||||
this.wcdbOpenAccount = lib.func(
|
const openAccount = lib.func(
|
||||||
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
||||||
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
||||||
|
this.wcdbOpenAccount = openAccount
|
||||||
|
this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction
|
||||||
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
||||||
this.wcdbGetSessions = lib.func(
|
this.wcdbGetSessions = lib.func(
|
||||||
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
||||||
@@ -1302,6 +1759,14 @@ export class Wcdb4Client {
|
|||||||
this.wcdbResolveImageHardlink = null
|
this.wcdbResolveImageHardlink = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.wcdbResolveVideoHardlink = lib.func(
|
||||||
|
'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)'
|
||||||
|
) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number
|
||||||
|
} catch {
|
||||||
|
this.wcdbResolveVideoHardlink = null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.wcdbGetEmoticonCdnUrl = lib.func(
|
this.wcdbGetEmoticonCdnUrl = lib.func(
|
||||||
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
|
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
|
||||||
@@ -1464,6 +1929,41 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
|
||||||
|
const handle = this.ensureHandle()
|
||||||
|
const outJson: WcdbVoidOut = [null]
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const code = Number(result)
|
||||||
|
if (code !== 0 || !outJson[0]) {
|
||||||
|
reject(new Error(`WCDB async call failed, code: ${code}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||||
|
resolve(JSON.parse(json) as T)
|
||||||
|
} catch (decodeError) {
|
||||||
|
reject(decodeError)
|
||||||
|
} finally {
|
||||||
|
this.wcdbFreeString?.(outJson[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
fn.async(...args, (error: unknown, result: unknown) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve(Number(result))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private ensureHandle(): number {
|
private ensureHandle(): number {
|
||||||
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
||||||
return this.handle
|
return this.handle
|
||||||
@@ -1490,7 +1990,21 @@ export class Wcdb4Client {
|
|||||||
'remark',
|
'remark',
|
||||||
'name'
|
'name'
|
||||||
])
|
])
|
||||||
return { username, nickname, raw: row }
|
const wechatNickname = this.pickString(row, [
|
||||||
|
'wechatNickname',
|
||||||
|
'wechat_nickname',
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'name'
|
||||||
|
])
|
||||||
|
const remark = this.pickString(row, [
|
||||||
|
'remark',
|
||||||
|
'remarkName',
|
||||||
|
'remark_name',
|
||||||
|
'contactRemark',
|
||||||
|
'contact_remark'
|
||||||
|
])
|
||||||
|
return { username, nickname, wechatNickname, remark, raw: row }
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
||||||
@@ -1536,6 +2050,19 @@ export class Wcdb4Client {
|
|||||||
'mesLocalID',
|
'mesLocalID',
|
||||||
'id'
|
'id'
|
||||||
])
|
])
|
||||||
|
const serverId = this.pickString(row, [
|
||||||
|
'server_id',
|
||||||
|
'serverId',
|
||||||
|
'svr_id',
|
||||||
|
'svrId',
|
||||||
|
'msg_svr_id',
|
||||||
|
'msgSvrId',
|
||||||
|
'message_id',
|
||||||
|
'messageId',
|
||||||
|
'new_msg_id',
|
||||||
|
'newMsgId',
|
||||||
|
'WCDB_CT_server_id'
|
||||||
|
])
|
||||||
const messageType = this.pickString(row, [
|
const messageType = this.pickString(row, [
|
||||||
'local_type',
|
'local_type',
|
||||||
'localType',
|
'localType',
|
||||||
@@ -1557,6 +2084,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
||||||
|
serverId: serverId || undefined,
|
||||||
mesDes: isSend ? 0 : 1,
|
mesDes: isSend ? 0 : 1,
|
||||||
messageType: messageType || '1',
|
messageType: messageType || '1',
|
||||||
msgCreateTime: String(createTime),
|
msgCreateTime: String(createTime),
|
||||||
@@ -1600,6 +2128,26 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
|
||||||
|
if (!this.wcdbGetDisplayNames) return
|
||||||
|
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||||
|
if (missing.length === 0) return
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'nickname',
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'remark',
|
||||||
|
'name'
|
||||||
|
]).forEach((name, username) => this.displayNameCache.set(username, name))
|
||||||
|
} catch {
|
||||||
|
// Names are optional; usernames remain usable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private hydrateAvatarUrls(usernames: string[]): void {
|
private hydrateAvatarUrls(usernames: string[]): void {
|
||||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||||
if (missing.length === 0) return
|
if (missing.length === 0) return
|
||||||
@@ -1634,6 +2182,27 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hydrateAvatarUrlsAsync(usernames: string[]): Promise<void> {
|
||||||
|
if (!this.wcdbGetAvatarUrls) return
|
||||||
|
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||||
|
if (missing.length === 0) return
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'avatarUrl',
|
||||||
|
'avatar_url',
|
||||||
|
'headImgUrl',
|
||||||
|
'm_nsHeadImgUrl',
|
||||||
|
'big_head_img_url',
|
||||||
|
'small_head_img_url'
|
||||||
|
]).forEach((avatar, username) => this.avatarCache.set(username, avatar))
|
||||||
|
} catch {
|
||||||
|
// Avatars are optional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
||||||
const result = new Map<string, string>()
|
const result = new Map<string, string>()
|
||||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface UserContact {
|
|||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
wechatNickname?: string
|
||||||
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatMessage {
|
export interface WechatMessage {
|
||||||
@@ -33,20 +35,12 @@ export interface GroupMemberInfo {
|
|||||||
export class WechatDb {
|
export class WechatDb {
|
||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
private chatMd5ToUsername = new Map<string, string>()
|
private chatMd5ToUsername = new Map<string, string>()
|
||||||
|
private chatTableMappingLoaded = false
|
||||||
|
|
||||||
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
||||||
// WCDB native init must run on the Electron main process; worker threads
|
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||||
// get -1006 from wcdb_init. Initialize the client synchronously here
|
await client.openAsync()
|
||||||
// (and keep create() async for callers that already await it).
|
return new WechatDb(rawKey, accountRoot, client)
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
|
||||||
client.open()
|
|
||||||
resolve(new WechatDb(rawKey, accountRoot, client))
|
|
||||||
} catch (error) {
|
|
||||||
reject(error instanceof Error ? error : new Error(String(error)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -56,15 +50,25 @@ export class WechatDb {
|
|||||||
initialChatTables?: { name: string; db_number: string }[]
|
initialChatTables?: { name: string; db_number: string }[]
|
||||||
) {
|
) {
|
||||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||||
const client =
|
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||||
clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
|
||||||
if (!clientOverride) client.open()
|
if (!clientOverride) client.open()
|
||||||
this.wcdb4Client = client
|
this.wcdb4Client = client
|
||||||
for (const table of initialChatTables || client.getChatTables()) {
|
for (const table of initialChatTables || []) {
|
||||||
if (table.name.startsWith('Chat_')) {
|
if (table.name.startsWith('Chat_')) {
|
||||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.chatTableMappingLoaded = Boolean(initialChatTables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureChatTableMapping(): void {
|
||||||
|
if (this.chatTableMappingLoaded) return
|
||||||
|
for (const table of this.wcdb4Client.getChatTables()) {
|
||||||
|
if (table.name.startsWith('Chat_')) {
|
||||||
|
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.chatTableMappingLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUserList(nicknameFilter?: string): UserContact[] {
|
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||||
@@ -74,7 +78,9 @@ export class WechatDb {
|
|||||||
.map((session) => ({
|
.map((session) => ({
|
||||||
m_nsUsrName: session.username,
|
m_nsUsrName: session.username,
|
||||||
nickname: session.nickname || session.username,
|
nickname: session.nickname || session.username,
|
||||||
avatar: session.avatar
|
avatar: session.avatar,
|
||||||
|
wechatNickname: session.wechatNickname,
|
||||||
|
remark: session.remark
|
||||||
}))
|
}))
|
||||||
.filter((contact) => {
|
.filter((contact) => {
|
||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
@@ -109,6 +115,7 @@ export class WechatDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username || !username.endsWith('@chatroom')) return {}
|
if (!username || !username.endsWith('@chatroom')) return {}
|
||||||
|
|
||||||
@@ -124,9 +131,8 @@ export class WechatDb {
|
|||||||
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
||||||
if (!chatroomId) return null
|
if (!chatroomId) return null
|
||||||
return (
|
return (
|
||||||
this.wcdb4Client
|
this.wcdb4Client.getGroupMembers(chatroomId).find((member) => member.m_nsUsrName === wxid) ||
|
||||||
.getGroupMembers(chatroomId)
|
null
|
||||||
.find((member) => member.m_nsUsrName === wxid) || null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +158,7 @@ export class WechatDb {
|
|||||||
endTime?: number,
|
endTime?: number,
|
||||||
options?: Wcdb4MessageQueryOptions
|
options?: Wcdb4MessageQueryOptions
|
||||||
): WechatMessage[] {
|
): WechatMessage[] {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||||
@@ -160,6 +167,24 @@ export class WechatDb {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUserMessagesAsync(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: Wcdb4MessageQueryOptions
|
||||||
|
): Promise<WechatMessage[]> {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
|
if (!username) return []
|
||||||
|
const messages = await this.wcdb4Client.getMessagesAsync(
|
||||||
|
username,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||||
|
}
|
||||||
|
|
||||||
public searchAllMessages(keyword: string): string | null {
|
public searchAllMessages(keyword: string): string | null {
|
||||||
const lowerKeyword = keyword.trim().toLowerCase()
|
const lowerKeyword = keyword.trim().toLowerCase()
|
||||||
if (!lowerKeyword) return null
|
if (!lowerKeyword) return null
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// 仅支持 WeChat 4.0:只扫描 xwechat_files;旧 V3 时代的 "WeChat Files" 不再纳入候选
|
||||||
|
const DB_ROOT_NAMES = new Set(['xwechat_files'])
|
||||||
|
const SKIPPED_DIRECTORY_NAMES = new Set([
|
||||||
|
'$recycle.bin',
|
||||||
|
'system volume information',
|
||||||
|
'windows',
|
||||||
|
'program files',
|
||||||
|
'program files (x86)',
|
||||||
|
'programdata',
|
||||||
|
'recovery'
|
||||||
|
])
|
||||||
|
const MAX_VISITED_DIRECTORIES_PER_DRIVE = 20_000
|
||||||
|
let cachedDiscoveredRoots: string[] | null = null
|
||||||
|
|
||||||
|
function unique(values: string[]): string[] {
|
||||||
|
return Array.from(new Set(values.map((value) => path.normalize(value))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowsDrives(): string[] {
|
||||||
|
if (process.platform !== 'win32') return []
|
||||||
|
const drives: string[] = []
|
||||||
|
for (let code = 67; code <= 90; code += 1) {
|
||||||
|
const root = `${String.fromCharCode(code)}:\\`
|
||||||
|
if (fs.existsSync(root)) drives.push(root)
|
||||||
|
}
|
||||||
|
return drives
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanWindowsDbRoots(driveRoots: string[], maxDepth = 3): string[] {
|
||||||
|
const results: string[] = []
|
||||||
|
|
||||||
|
for (const driveRoot of driveRoots) {
|
||||||
|
const queue: Array<{ directory: string; depth: number }> = [{ directory: driveRoot, depth: 0 }]
|
||||||
|
let visited = 0
|
||||||
|
|
||||||
|
while (queue.length > 0 && visited < MAX_VISITED_DIRECTORIES_PER_DRIVE) {
|
||||||
|
const current = queue.shift()
|
||||||
|
if (!current || current.depth >= maxDepth) continue
|
||||||
|
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current.directory, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
||||||
|
const lowered = entry.name.toLowerCase()
|
||||||
|
const fullPath = path.join(current.directory, entry.name)
|
||||||
|
const depth = current.depth + 1
|
||||||
|
visited += 1
|
||||||
|
|
||||||
|
if (DB_ROOT_NAMES.has(lowered)) {
|
||||||
|
results.push(fullPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (depth < maxDepth && !SKIPPED_DIRECTORY_NAMES.has(lowered)) {
|
||||||
|
queue.push({ directory: fullPath, depth })
|
||||||
|
}
|
||||||
|
if (visited >= MAX_VISITED_DIRECTORIES_PER_DRIVE) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverWindowsDbRoots(): string[] {
|
||||||
|
if (!cachedDiscoveredRoots) {
|
||||||
|
cachedDiscoveredRoots = scanWindowsDbRoots(getWindowsDrives(), 3)
|
||||||
|
}
|
||||||
|
return [...cachedDiscoveredRoots]
|
||||||
|
}
|
||||||
@@ -30,15 +30,54 @@ import type {
|
|||||||
AIVisionTestResult,
|
AIVisionTestResult,
|
||||||
LegacyAIConfig
|
LegacyAIConfig
|
||||||
} from '../shared/ai-provider'
|
} from '../shared/ai-provider'
|
||||||
|
import type {
|
||||||
|
ImageAnalysisRequest,
|
||||||
|
ImageAnalysisResponse,
|
||||||
|
ImageCandidate,
|
||||||
|
ImageCandidateQuery,
|
||||||
|
ImageInsight
|
||||||
|
} from '../shared/image-insight'
|
||||||
|
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
|
||||||
|
import type { CacheSummary } from '../shared/cache'
|
||||||
|
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
| { type: 'voice'; duration?: number }
|
| { type: 'voice'; duration?: number }
|
||||||
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
|
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
|
||||||
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||||
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
|
| {
|
||||||
|
type: 'share'
|
||||||
|
title: string
|
||||||
|
des?: string
|
||||||
|
url: string
|
||||||
|
appname?: string
|
||||||
|
typeVal?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'miniProgram'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
appName?: string
|
||||||
|
iconUrl?: string
|
||||||
|
thumbMd5?: string
|
||||||
|
thumbDatName?: string
|
||||||
|
thumbDataUrl?: string
|
||||||
|
}
|
||||||
|
| { type: 'redPacket'; title: string; description?: string; url?: string }
|
||||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||||
|
| {
|
||||||
|
type: 'video'
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'sticker'
|
type: 'sticker'
|
||||||
md5?: string
|
md5?: string
|
||||||
@@ -63,6 +102,16 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: {
|
api: {
|
||||||
|
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
||||||
|
getAppLogPath: () => Promise<string>
|
||||||
|
revealAppLog: () => Promise<void>
|
||||||
|
getAppUpdateState: () => Promise<AppUpdateState>
|
||||||
|
checkAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||||
|
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||||
|
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
||||||
|
getCacheSummary: () => Promise<CacheSummary>
|
||||||
|
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
|
||||||
initDb: (
|
initDb: (
|
||||||
key: string
|
key: string
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
@@ -71,6 +120,11 @@ declare global {
|
|||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
} | null>
|
} | null>
|
||||||
|
getStartupCache: () => Promise<{
|
||||||
|
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
|
contacts: Contact[]
|
||||||
|
updatedAt: number
|
||||||
|
} | null>
|
||||||
getContacts: (filter?: string) => Promise<Contact[]>
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||||
getCachedMessages: (
|
getCachedMessages: (
|
||||||
@@ -78,6 +132,26 @@ declare global {
|
|||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number
|
||||||
) => Promise<Message[]>
|
) => Promise<Message[]>
|
||||||
|
getCachedMessagePage: (
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
) => Promise<{
|
||||||
|
hit: boolean
|
||||||
|
messages: Message[]
|
||||||
|
groupSnapshot?: {
|
||||||
|
roomId: string
|
||||||
|
memberCount: number
|
||||||
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}>
|
||||||
getMessages: (
|
getMessages: (
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
@@ -87,7 +161,14 @@ declare global {
|
|||||||
getGroupSnapshot: (userMd5: string) => Promise<{
|
getGroupSnapshot: (userMd5: string) => Promise<{
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
members: { wxid: string; nickname: string; avatar: string }[]
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
} | null>
|
} | null>
|
||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
aiChat: (
|
aiChat: (
|
||||||
@@ -124,7 +205,7 @@ declare global {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string
|
data?: string
|
||||||
@@ -132,10 +213,17 @@ declare global {
|
|||||||
isThumb?: boolean
|
isThumb?: boolean
|
||||||
filePath?: string
|
filePath?: string
|
||||||
}>
|
}>
|
||||||
|
getVideo: (
|
||||||
|
hashes: string[]
|
||||||
|
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
md5?: string
|
md5?: string
|
||||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
|
startExport: (request: ExportRequest) => Promise<ExportResult>
|
||||||
|
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>
|
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||||
listGeneratedReports: () => Promise<ReportHistoryResult>
|
listGeneratedReports: () => Promise<ReportHistoryResult>
|
||||||
saveGeneratedReport: (
|
saveGeneratedReport: (
|
||||||
@@ -172,6 +260,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -196,6 +291,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -208,6 +310,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -218,6 +327,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -238,7 +354,9 @@ declare global {
|
|||||||
}>
|
}>
|
||||||
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
||||||
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
||||||
disconnectDb: () => Promise<{ success: boolean; error?: string }>
|
disconnectDb: (options?: {
|
||||||
|
closeNative?: boolean
|
||||||
|
}) => Promise<{ success: boolean; error?: string }>
|
||||||
apiStatus: () => Promise<{
|
apiStatus: () => Promise<{
|
||||||
running: boolean
|
running: boolean
|
||||||
host: string
|
host: string
|
||||||
@@ -270,6 +388,30 @@ declare global {
|
|||||||
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
|
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
|
||||||
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
|
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
|
||||||
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
|
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
// ============================================================
|
||||||
|
// AI 图片理解基础设施(ImageInsightService)
|
||||||
|
// ============================================================
|
||||||
|
imageListCandidates: (query: ImageCandidateQuery) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
candidates: ImageCandidate[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
imageAnalyze: (request: ImageAnalysisRequest) => Promise<ImageAnalysisResponse>
|
||||||
|
getImageInsight: (imageHash: string) => Promise<{ success: boolean; insight?: ImageInsight }>
|
||||||
|
listImageInsights: (
|
||||||
|
sessionId: string,
|
||||||
|
limit?: number
|
||||||
|
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
||||||
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
|
clearAgentHubLogs: () => Promise<void>
|
||||||
|
startAgentHubLogin: () => Promise<AgentHubActionResult>
|
||||||
|
cancelAgentHubLogin: () => Promise<AgentHubActionResult>
|
||||||
|
reconnectAgentHub: () => Promise<AgentHubActionResult>
|
||||||
|
disconnectAgentHub: () => Promise<AgentHubActionResult>
|
||||||
|
selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }>
|
||||||
|
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void
|
||||||
|
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,46 @@ import type {
|
|||||||
AIVisionTestRequest,
|
AIVisionTestRequest,
|
||||||
LegacyAIConfig
|
LegacyAIConfig
|
||||||
} from '../shared/ai-provider'
|
} from '../shared/ai-provider'
|
||||||
|
import type {
|
||||||
|
ImageAnalysisRequest,
|
||||||
|
ImageAnalysisResponse,
|
||||||
|
ImageCandidate,
|
||||||
|
ImageCandidateQuery,
|
||||||
|
ImageInsight
|
||||||
|
} from '../shared/image-insight'
|
||||||
|
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import type { AppUpdateState } from '../shared/app-update'
|
||||||
|
import type { CacheSummary } from '../shared/cache'
|
||||||
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
|
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||||
|
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||||
|
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||||
|
getAppUpdateState: (): Promise<AppUpdateState> => ipcRenderer.invoke('app-update:getState'),
|
||||||
|
checkAppUpdate: () => ipcRenderer.invoke('app-update:check'),
|
||||||
|
downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'),
|
||||||
|
installAppUpdate: () => ipcRenderer.invoke('app-update:install'),
|
||||||
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void =>
|
||||||
|
callback(state)
|
||||||
|
ipcRenderer.on('app-update:state', listener)
|
||||||
|
return () => ipcRenderer.removeListener('app-update:state', listener)
|
||||||
|
},
|
||||||
|
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
||||||
|
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
|
||||||
|
ipcRenderer.invoke('cache:clear', scope),
|
||||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
|
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||||
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
||||||
|
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('db:getCachedMessagePage', userMd5, startTime, endTime),
|
||||||
getMessages: (
|
getMessages: (
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
@@ -45,9 +76,19 @@ const api = {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
|
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
|
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||||
|
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
|
||||||
|
revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
|
||||||
|
onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
|
||||||
|
callback(progress)
|
||||||
|
ipcRenderer.on('export:progress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('export:progress', listener)
|
||||||
|
},
|
||||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||||
ipcRenderer.invoke('report:export', request),
|
ipcRenderer.invoke('report:export', request),
|
||||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||||
@@ -98,7 +139,8 @@ const api = {
|
|||||||
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
||||||
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
||||||
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
||||||
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
|
disconnectDb: (options?: { closeNative?: boolean }) =>
|
||||||
|
ipcRenderer.invoke('db:disconnect', options),
|
||||||
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||||
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||||
apiStop: () => ipcRenderer.invoke('api:stop'),
|
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||||
@@ -108,7 +150,46 @@ const api = {
|
|||||||
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
|
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
|
||||||
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
|
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
|
||||||
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
|
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
|
||||||
copyText: (text: string) => ipcRenderer.invoke('api:copyText', text)
|
copyText: (text: string) => ipcRenderer.invoke('api:copyText', text),
|
||||||
|
// ============================================================
|
||||||
|
// AI 图片理解基础设施(ImageInsightService)
|
||||||
|
// ============================================================
|
||||||
|
imageListCandidates: (
|
||||||
|
query: ImageCandidateQuery
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
candidates: ImageCandidate[]
|
||||||
|
error?: string
|
||||||
|
}> => ipcRenderer.invoke('image:listCandidates', query),
|
||||||
|
imageAnalyze: (request: ImageAnalysisRequest): Promise<ImageAnalysisResponse> =>
|
||||||
|
ipcRenderer.invoke('image:analyze', request),
|
||||||
|
getImageInsight: (imageHash: string): Promise<{ success: boolean; insight?: ImageInsight }> =>
|
||||||
|
ipcRenderer.invoke('image:getInsight', imageHash),
|
||||||
|
listImageInsights: (
|
||||||
|
sessionId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
|
||||||
|
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
||||||
|
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||||
|
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
||||||
|
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
||||||
|
startAgentHubLogin: () => ipcRenderer.invoke('agent-hub:startLogin'),
|
||||||
|
cancelAgentHubLogin: () => ipcRenderer.invoke('agent-hub:cancelLogin'),
|
||||||
|
reconnectAgentHub: () => ipcRenderer.invoke('agent-hub:reconnect'),
|
||||||
|
disconnectAgentHub: () => ipcRenderer.invoke('agent-hub:disconnect'),
|
||||||
|
selectAgentHubTestImage: () => ipcRenderer.invoke('agent-hub:selectTestImage'),
|
||||||
|
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, status: AgentHubStatus): void =>
|
||||||
|
callback(status)
|
||||||
|
ipcRenderer.on('agent-hub:status', listener)
|
||||||
|
return () => ipcRenderer.removeListener('agent-hub:status', listener)
|
||||||
|
},
|
||||||
|
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, entry: AgentHubLogEntry): void =>
|
||||||
|
callback(entry)
|
||||||
|
ipcRenderer.on('agent-hub:log', listener)
|
||||||
|
return () => ipcRenderer.removeListener('agent-hub:log', listener)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
|
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:;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ChatHeader } from './chat/ChatHeader'
|
|||||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||||
import { DataTrustBar } from './chat/DataTrustBar'
|
import { DataTrustBar } from './chat/DataTrustBar'
|
||||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||||
import { ExportRange } from './chat/ExportMenu'
|
|
||||||
import { MessageList } from './chat/MessageList'
|
import { MessageList } from './chat/MessageList'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
@@ -12,51 +11,14 @@ interface ChatWindowProps {
|
|||||||
messages: Message[]
|
messages: Message[]
|
||||||
isLoadingMessages?: boolean
|
isLoadingMessages?: boolean
|
||||||
contentFilter?: string
|
contentFilter?: string
|
||||||
dateRange?: string
|
|
||||||
onContentFilterChange?: (keyword: string) => void
|
onContentFilterChange?: (keyword: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
|
onReloadAvatars?: () => Promise<void>
|
||||||
|
onLoadOlderMessages?: () => Promise<void>
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
}
|
jumpToTime?: number | null
|
||||||
|
|
||||||
const MAX_RENDERED_MESSAGES = 600
|
|
||||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
|
||||||
today: '今天',
|
|
||||||
yesterday: '昨日',
|
|
||||||
'7': '7 天',
|
|
||||||
'30': '30 天',
|
|
||||||
all: '全部'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatClock = (date: Date): string =>
|
|
||||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
||||||
|
|
||||||
const formatRangeDate = (date: Date, now: Date): string => {
|
|
||||||
const clock = formatClock(date)
|
|
||||||
if (date.getFullYear() === now.getFullYear()) {
|
|
||||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
|
||||||
}
|
|
||||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChatHeaderRangeLabel = (range: string): string => {
|
|
||||||
const now = new Date()
|
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
||||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
|
||||||
|
|
||||||
if (range === 'today') return `今天 00:00—现在`
|
|
||||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
|
||||||
if (range === '7') {
|
|
||||||
const start = new Date(Date.now() - 7 * 86400000)
|
|
||||||
return `${formatRangeDate(start, now)}—现在`
|
|
||||||
}
|
|
||||||
if (range === '30') {
|
|
||||||
const start = new Date(Date.now() - 30 * 86400000)
|
|
||||||
return `${formatRangeDate(start, now)}—现在`
|
|
||||||
}
|
|
||||||
if (range === 'all') return '全部记录'
|
|
||||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
@@ -64,12 +26,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
messages,
|
messages,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
dateRange = 'today',
|
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
|
onReloadAvatars,
|
||||||
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
isAiLoading = false
|
isAiLoading = false,
|
||||||
|
jumpToTime
|
||||||
}) => {
|
}) => {
|
||||||
const isGroupChat = Boolean(
|
const isGroupChat = Boolean(
|
||||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||||
@@ -86,6 +50,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
)
|
)
|
||||||
const [showAvatar, setShowAvatar] = useState(true)
|
const [showAvatar, setShowAvatar] = useState(true)
|
||||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||||
|
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||||
|
const previousScrollTopRef = useRef(0)
|
||||||
|
|
||||||
const scrollToBottom = useCallback((): void => {
|
const scrollToBottom = useCallback((): void => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||||
@@ -95,13 +61,37 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
const target = event.currentTarget
|
const target = event.currentTarget
|
||||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||||
setIsAtLatest(distanceToBottom <= 24)
|
if (distanceToBottom <= 24) {
|
||||||
|
setIsAtLatest(true)
|
||||||
|
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
|
||||||
|
setIsAtLatest(false)
|
||||||
|
}
|
||||||
|
previousScrollTopRef.current = target.scrollTop
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
previousScrollTopRef.current = 0
|
||||||
|
setIsAtLatest(true)
|
||||||
|
}, [contact?.md5])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
|
||||||
|
}, [jumpToTime])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||||
return () => window.cancelAnimationFrame(frame)
|
return () => window.cancelAnimationFrame(frame)
|
||||||
}, [messages, scrollToBottom])
|
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||||
|
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||||
|
if (!content) return
|
||||||
|
const observer = new ResizeObserver(() => scrollToBottom())
|
||||||
|
observer.observe(content)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
|
||||||
|
|
||||||
const openImagePreview = (imageUrl: string): void => {
|
const openImagePreview = (imageUrl: string): void => {
|
||||||
setPreviewImage(imageUrl)
|
setPreviewImage(imageUrl)
|
||||||
@@ -154,6 +144,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
imageDragRef.current = null
|
imageDragRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReloadAvatars = async (): Promise<void> => {
|
||||||
|
if (!onReloadAvatars || isReloadingAvatars) return
|
||||||
|
setIsReloadingAvatars(true)
|
||||||
|
try {
|
||||||
|
await onReloadAvatars()
|
||||||
|
} finally {
|
||||||
|
setIsReloadingAvatars(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewImage) return
|
if (!previewImage) return
|
||||||
|
|
||||||
@@ -172,65 +172,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
}, [previewImage])
|
}, [previewImage])
|
||||||
|
|
||||||
const handleExport = (days: ExportRange): void => {
|
|
||||||
if (!messages.length) return
|
|
||||||
|
|
||||||
let filtered = messages
|
|
||||||
if (days !== 'all') {
|
|
||||||
const now = new Date()
|
|
||||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
|
||||||
|
|
||||||
filtered = messages.filter((m) => {
|
|
||||||
const parsed = new Date(m.datetime).getTime()
|
|
||||||
if (isNaN(parsed)) return true
|
|
||||||
|
|
||||||
if (days === 0) {
|
|
||||||
// 今天
|
|
||||||
return parsed >= startOfDay
|
|
||||||
} else if (days === 1) {
|
|
||||||
// 昨天
|
|
||||||
const startOfYesterday = startOfDay - 86400000
|
|
||||||
return parsed >= startOfYesterday && parsed < startOfDay
|
|
||||||
} else if (days === 7) {
|
|
||||||
// 过去 7 天
|
|
||||||
const startOf7DaysAgo = startOfDay - 7 * 86400000
|
|
||||||
return parsed >= startOf7DaysAgo
|
|
||||||
} else if (days === 30) {
|
|
||||||
// 过去 30 天
|
|
||||||
const startOf30DaysAgo = startOfDay - 30 * 86400000
|
|
||||||
return parsed >= startOf30DaysAgo
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = ['发送者', '类型', '时间', '内容']
|
|
||||||
const csvContent = [
|
|
||||||
headers.join(','),
|
|
||||||
...filtered.map((m) => {
|
|
||||||
let prefix = ''
|
|
||||||
if (isGroupChat) {
|
|
||||||
prefix = m.name ? `${m.name}: ` : ''
|
|
||||||
} else {
|
|
||||||
const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
|
|
||||||
prefix = `${name}: `
|
|
||||||
}
|
|
||||||
const fullContent = `${prefix}${m.content}`
|
|
||||||
const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
|
|
||||||
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
|
|
||||||
})
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredMessages = React.useMemo(() => {
|
const filteredMessages = React.useMemo(() => {
|
||||||
return messages.filter((msg) => {
|
return messages.filter((msg) => {
|
||||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||||
@@ -242,51 +183,44 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
return typeMatch && contentMatch
|
return typeMatch && contentMatch
|
||||||
})
|
})
|
||||||
}, [messages, contentFilter])
|
}, [messages, contentFilter])
|
||||||
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
|
|
||||||
const renderedMessages = React.useMemo(
|
|
||||||
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
|
|
||||||
[filteredMessages]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!contact) return <EmptyConversationState />
|
if (!contact) return <EmptyConversationState />
|
||||||
|
|
||||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-window">
|
<div className="chat-window">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
contact={contact}
|
contact={contact}
|
||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
dateRangeLabel={dateRangeLabel}
|
|
||||||
loadedCount={messages.length}
|
loadedCount={messages.length}
|
||||||
filteredCount={filteredMessages.length}
|
filteredCount={filteredMessages.length}
|
||||||
contentFilter={contentFilter || ''}
|
contentFilter={contentFilter || ''}
|
||||||
isAiLoading={isAiLoading}
|
isAiLoading={isAiLoading}
|
||||||
canExport={messages.length > 0}
|
|
||||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onRefreshData={onRefreshData}
|
onRefreshData={onRefreshData}
|
||||||
onExport={handleExport}
|
|
||||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||||
/>
|
/>
|
||||||
<DataTrustBar messageCount={messages.length} />
|
<DataTrustBar messageCount={messages.length} />
|
||||||
<MessageList
|
<MessageList
|
||||||
contact={contact}
|
contact={contact}
|
||||||
messages={renderedMessages}
|
messages={filteredMessages}
|
||||||
hiddenMessageCount={hiddenMessageCount}
|
hiddenMessageCount={0}
|
||||||
isLoadingMessages={isLoadingMessages}
|
isLoadingMessages={isLoadingMessages}
|
||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
listRef={messageListRef}
|
listRef={messageListRef}
|
||||||
bottomRef={messagesEndRef}
|
bottomRef={messagesEndRef}
|
||||||
onScroll={handleMessageListScroll}
|
onScroll={handleMessageListScroll}
|
||||||
|
onReachTop={onLoadOlderMessages}
|
||||||
onImageClick={openImagePreview}
|
onImageClick={openImagePreview}
|
||||||
|
jumpToTime={jumpToTime}
|
||||||
/>
|
/>
|
||||||
<ChatStatusBar
|
<ChatStatusBar
|
||||||
count={renderedMessages.length}
|
count={filteredMessages.length}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
isAtLatest={isAtLatest}
|
isAtLatest={isAtLatest}
|
||||||
|
isReloadingAvatars={isReloadingAvatars}
|
||||||
onShowAvatarChange={setShowAvatar}
|
onShowAvatarChange={setShowAvatar}
|
||||||
|
onReloadAvatars={() => void handleReloadAvatars()}
|
||||||
onJumpToLatest={scrollToBottom}
|
onJumpToLatest={scrollToBottom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||||
|
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||||
|
|
||||||
|
interface DatabaseConnectionPageProps {
|
||||||
|
platform: string
|
||||||
|
mode: DatabaseConnectionMode
|
||||||
|
dbKey: string
|
||||||
|
dbRoot: string
|
||||||
|
showDbKey: boolean
|
||||||
|
isFetching: boolean
|
||||||
|
status: string
|
||||||
|
statusKind: DatabaseConnectionStatusKind
|
||||||
|
showMacKeyFaq: boolean
|
||||||
|
macKeyFaqUrl: string
|
||||||
|
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||||
|
onDbKeyChange: (value: string) => void
|
||||||
|
onDbRootChange: (value: string) => void
|
||||||
|
onToggleDbKey: () => void
|
||||||
|
onAutoGetKey: () => void
|
||||||
|
onManualConnect: () => void
|
||||||
|
onPasteKey: () => void
|
||||||
|
onClearKey: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineIcon({
|
||||||
|
name
|
||||||
|
}: {
|
||||||
|
name: 'shield' | 'lock' | 'cloud' | 'info' | 'database'
|
||||||
|
}): React.ReactElement {
|
||||||
|
const paths = {
|
||||||
|
shield: <path d="M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z" />,
|
||||||
|
lock: <path d="M7 10V8a5 5 0 0 1 10 0v2m-11 0h12v10H6V10Z" />,
|
||||||
|
cloud: (
|
||||||
|
<path d="m4 4 16 16M7.5 16H6a4 4 0 0 1-.5-8A6.5 6.5 0 0 1 17 6.8M18.5 10A4 4 0 0 1 18 18h-7" />
|
||||||
|
),
|
||||||
|
info: <path d="M12 8h.01M11 12h1v4h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" />,
|
||||||
|
database: (
|
||||||
|
<path d="M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3Zm0 0v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6m-14 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{paths[name]}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
{!visible && <path d="M4 4l16 16" />}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StoragePathHelp(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span className="database-login-path-help">
|
||||||
|
<span
|
||||||
|
className="database-login-path-help-icon"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-describedby="storage-path-help"
|
||||||
|
>
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
<span id="storage-path-help" className="database-login-path-tooltip" role="tooltip">
|
||||||
|
打开微信设置,在缓存管理中复制存储路径,然后粘贴到这里。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatabaseConnectionPage({
|
||||||
|
platform,
|
||||||
|
mode,
|
||||||
|
dbKey,
|
||||||
|
dbRoot,
|
||||||
|
showDbKey,
|
||||||
|
isFetching,
|
||||||
|
status,
|
||||||
|
statusKind,
|
||||||
|
showMacKeyFaq,
|
||||||
|
macKeyFaqUrl,
|
||||||
|
onModeChange,
|
||||||
|
onDbKeyChange,
|
||||||
|
onDbRootChange,
|
||||||
|
onToggleDbKey,
|
||||||
|
onAutoGetKey,
|
||||||
|
onManualConnect,
|
||||||
|
onPasteKey,
|
||||||
|
onClearKey
|
||||||
|
}: DatabaseConnectionPageProps): React.ReactElement {
|
||||||
|
const isMac = platform === 'darwin'
|
||||||
|
const defaultPath = isMac
|
||||||
|
? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/'
|
||||||
|
: 'C:\\Users\\...\\WeChat Files\\Msg'
|
||||||
|
const keyIsValid = /^[0-9a-f]{64}$/i.test(dbKey.trim().replace(/^0x/i, ''))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="database-login-page">
|
||||||
|
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
|
||||||
|
<div className="database-login-brand-content">
|
||||||
|
<div className="database-login-logo" aria-hidden="true">
|
||||||
|
<LineIcon name="database" />
|
||||||
|
</div>
|
||||||
|
<h1>WechatExplorer</h1>
|
||||||
|
<p className="database-login-tagline">你的本地微信聊天档案</p>
|
||||||
|
<p className="database-login-description">
|
||||||
|
连接本机微信数据库,开始检索、整理和分析聊天记录。
|
||||||
|
</p>
|
||||||
|
<div className="database-login-promises">
|
||||||
|
<div>
|
||||||
|
<LineIcon name="shield" />
|
||||||
|
<span>仅限本机</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LineIcon name="lock" />
|
||||||
|
<span>加密保存</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LineIcon name="cloud" />
|
||||||
|
<span>不会上传</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="database-login-brand-footer">LOCAL-FIRST · PRIVATE · SECURE</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="database-login-workspace" aria-label="数据库连接">
|
||||||
|
<div className="database-login-panel">
|
||||||
|
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'automatic'}
|
||||||
|
className={mode === 'automatic' ? 'active' : ''}
|
||||||
|
onClick={() => onModeChange('automatic')}
|
||||||
|
>
|
||||||
|
自动获取
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'manual'}
|
||||||
|
className={mode === 'manual' ? 'active' : ''}
|
||||||
|
onClick={() => onModeChange('manual')}
|
||||||
|
>
|
||||||
|
手动输入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'automatic' ? (
|
||||||
|
<div className="database-login-auto" role="tabpanel">
|
||||||
|
<div className={`database-login-state-card ${statusKind}`}>
|
||||||
|
<div className="database-login-state-heading">
|
||||||
|
<span className="database-login-state-icon">
|
||||||
|
<LineIcon name="info" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{statusKind === 'error'
|
||||||
|
? status
|
||||||
|
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="database-login-diagnostics">
|
||||||
|
<div>
|
||||||
|
<dt>微信客户端</dt>
|
||||||
|
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>
|
||||||
|
存储路径
|
||||||
|
<StoragePathHelp />
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="database-login-path-input-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dbRoot}
|
||||||
|
onChange={(event) => onDbRootChange(event.target.value)}
|
||||||
|
placeholder={defaultPath}
|
||||||
|
title={dbRoot || defaultPath}
|
||||||
|
aria-label="微信数据存储路径"
|
||||||
|
spellCheck={false}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<span className="database-login-path-value" role="status">
|
||||||
|
{dbRoot || defaultPath}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>数据库状态</dt>
|
||||||
|
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="database-login-primary"
|
||||||
|
onClick={onAutoGetKey}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{isFetching
|
||||||
|
? '正在获取密钥…'
|
||||||
|
: statusKind === 'error'
|
||||||
|
? '重新检测'
|
||||||
|
: '自动获取密钥'}
|
||||||
|
</button>
|
||||||
|
{showMacKeyFaq && (
|
||||||
|
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||||
|
查看详情 · 连接帮助
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="database-login-manual" role="tabpanel">
|
||||||
|
<div className="database-login-field">
|
||||||
|
<label htmlFor="database-login-key">数据库密钥</label>
|
||||||
|
<div className="database-login-key-input">
|
||||||
|
<input
|
||||||
|
id="database-login-key"
|
||||||
|
type={showDbKey ? 'text' : 'password'}
|
||||||
|
value={dbKey}
|
||||||
|
onChange={(event) => onDbKeyChange(event.target.value)}
|
||||||
|
placeholder="输入或粘贴 64 位数据库密钥"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleDbKey}
|
||||||
|
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
||||||
|
>
|
||||||
|
<EyeIcon visible={showDbKey} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small>密钥通过系统安全存储加密保存在当前设备。</small>
|
||||||
|
</div>
|
||||||
|
{platform === 'win32' && (
|
||||||
|
<div className="database-login-field">
|
||||||
|
<label htmlFor="database-login-root">
|
||||||
|
微信数据目录
|
||||||
|
<StoragePathHelp />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="database-login-root"
|
||||||
|
value={dbRoot}
|
||||||
|
onChange={(event) => onDbRootChange(event.target.value)}
|
||||||
|
placeholder={defaultPath}
|
||||||
|
title={dbRoot || defaultPath}
|
||||||
|
spellCheck={false}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="database-login-primary"
|
||||||
|
onClick={onManualConnect}
|
||||||
|
disabled={!keyIsValid}
|
||||||
|
>
|
||||||
|
连接数据库
|
||||||
|
</button>
|
||||||
|
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
|
||||||
|
从剪贴板粘贴并安全保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="database-login-footer-actions">
|
||||||
|
<button type="button" onClick={onClearKey}>
|
||||||
|
清除已保存密钥
|
||||||
|
</button>
|
||||||
|
<span>WechatExplorer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import type { JSX, MouseEvent } from 'react'
|
import type { JSX, MouseEvent } from 'react'
|
||||||
|
import { getCachedLoadedImage, requestImage } from './image-loader'
|
||||||
|
|
||||||
interface ImageBubbleProps {
|
interface ImageBubbleProps {
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
isThumb?: boolean
|
isThumb?: boolean
|
||||||
|
fallbackUrl?: string
|
||||||
onImageClick?: (imageUrl: string) => void
|
onImageClick?: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,39 +15,91 @@ export function ImageBubble({
|
|||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
sessionId,
|
sessionId,
|
||||||
isThumb = false,
|
fallbackUrl,
|
||||||
onImageClick
|
onImageClick
|
||||||
}: ImageBubbleProps): JSX.Element {
|
}: ImageBubbleProps): JSX.Element {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
|
||||||
|
preferThumbnail: true
|
||||||
|
})
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isThumbnail, setIsThumbnail] = useState(false)
|
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||||
|
const [usingFallback, setUsingFallback] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
const backgroundUpgradeRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const upgradeOriginalInBackground = useCallback(() => {
|
||||||
|
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
|
||||||
|
backgroundUpgradeRef.current = true
|
||||||
|
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
|
||||||
|
.then((original) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setImageUrl(original.data)
|
||||||
|
setIsThumbnail(false)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [imageDatName, imageMd5, isThumbnail, sessionId])
|
||||||
|
|
||||||
const loadImage = useCallback(async () => {
|
const loadImage = useCallback(async () => {
|
||||||
if (imageUrl || loading) return
|
if (imageUrl || loading) return
|
||||||
if (!imageMd5 && !imageDatName) {
|
if (!imageMd5 && !imageDatName) {
|
||||||
|
if (fallbackUrl) {
|
||||||
|
setImageUrl(fallbackUrl)
|
||||||
|
setUsingFallback(true)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
setError('缺少图片标识')
|
setError('缺少图片标识')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
const result = await requestImage(
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
imageMd5,
|
||||||
setImageUrl(result.data)
|
imageDatName,
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
sessionId,
|
||||||
|
{ preferThumbnail: true },
|
||||||
|
0
|
||||||
|
)
|
||||||
|
setImageUrl(result.data)
|
||||||
|
setUsingFallback(false)
|
||||||
|
setIsThumbnail(result.isThumbnail)
|
||||||
|
setError(null)
|
||||||
|
if (result.isThumbnail) upgradeOriginalInBackground()
|
||||||
|
} catch (error) {
|
||||||
|
if (fallbackUrl) {
|
||||||
|
setImageUrl(fallbackUrl)
|
||||||
|
setUsingFallback(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '加载图片失败')
|
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
setError('加载图片失败')
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
}, [
|
||||||
|
fallbackUrl,
|
||||||
|
imageDatName,
|
||||||
|
imageMd5,
|
||||||
|
imageUrl,
|
||||||
|
loading,
|
||||||
|
sessionId,
|
||||||
|
upgradeOriginalInBackground
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||||
|
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageUrl || loading || error) return
|
if (imageUrl || loading || error) return
|
||||||
@@ -88,12 +142,11 @@ export function ImageBubble({
|
|||||||
|
|
||||||
setUpgrading(true)
|
setUpgrading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
|
||||||
force: true
|
if (result.data.startsWith('data:image/')) {
|
||||||
})
|
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setUsingFallback(false)
|
||||||
|
setIsThumbnail(result.isThumbnail)
|
||||||
setError(null)
|
setError(null)
|
||||||
onImageClick?.(result.data)
|
onImageClick?.(result.data)
|
||||||
return
|
return
|
||||||
@@ -136,7 +189,11 @@ export function ImageBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||||
<img src={imageUrl} alt="图片" className="image-content" />
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="图片"
|
||||||
|
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||||
|
/>
|
||||||
{(upgrading || isThumbnail) && (
|
{(upgrading || isThumbnail) && (
|
||||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { JSX, MouseEvent } from 'react'
|
import type { JSX, MouseEvent } from 'react'
|
||||||
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
||||||
|
import { ImageBubble } from './ImageBubble'
|
||||||
|
|
||||||
const stickerDataUrlCache = new Map<string, string>()
|
const stickerDataUrlCache = new Map<string, string>()
|
||||||
|
|
||||||
interface RichMessageBubbleProps {
|
interface RichMessageBubbleProps {
|
||||||
contentData: ParsedContent
|
contentData: ParsedContent
|
||||||
|
sessionId?: string
|
||||||
|
onImageClick?: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
|
export function RichMessageBubble({
|
||||||
|
contentData,
|
||||||
|
sessionId,
|
||||||
|
onImageClick
|
||||||
|
}: RichMessageBubbleProps): JSX.Element {
|
||||||
switch (contentData.type) {
|
switch (contentData.type) {
|
||||||
case 'location':
|
case 'location':
|
||||||
return <LocationBubble data={contentData} />
|
return <LocationBubble data={contentData} />
|
||||||
@@ -17,12 +24,22 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
|
|||||||
return <CardBubble data={contentData} />
|
return <CardBubble data={contentData} />
|
||||||
case 'share':
|
case 'share':
|
||||||
return <ShareBubble data={contentData} />
|
return <ShareBubble data={contentData} />
|
||||||
|
case 'miniProgram':
|
||||||
|
return (
|
||||||
|
<MiniProgramBubble
|
||||||
|
data={contentData}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'redPacket':
|
||||||
|
return <RedPacketBubble data={contentData} />
|
||||||
case 'voip':
|
case 'voip':
|
||||||
return <VoipBubble data={contentData} />
|
return <VoipBubble data={contentData} />
|
||||||
case 'sticker':
|
case 'sticker':
|
||||||
return <StickerBubble data={contentData} />
|
return <StickerBubble data={contentData} />
|
||||||
case 'quote':
|
case 'quote':
|
||||||
return <QuoteBubble data={contentData} />
|
return <QuoteBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
|
||||||
case 'system':
|
case 'system':
|
||||||
return <SystemBubble data={contentData} />
|
return <SystemBubble data={contentData} />
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
@@ -110,6 +127,83 @@ function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MiniProgramBubble({
|
||||||
|
data,
|
||||||
|
sessionId,
|
||||||
|
onImageClick
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'miniProgram' }>
|
||||||
|
sessionId?: string
|
||||||
|
onImageClick?: (imageUrl: string) => void
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mini-program-message">
|
||||||
|
<div className="mini-program-title">{data.title}</div>
|
||||||
|
{data.description && <div className="mini-program-description">{data.description}</div>}
|
||||||
|
{data.thumbDataUrl ? (
|
||||||
|
<div className="mini-program-preview">
|
||||||
|
<img
|
||||||
|
className="mini-program-inline-image"
|
||||||
|
src={data.thumbDataUrl}
|
||||||
|
alt={data.title}
|
||||||
|
onClick={() => onImageClick?.(data.thumbDataUrl || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data.thumbMd5 ? (
|
||||||
|
<div className="mini-program-preview">
|
||||||
|
<ImageBubble
|
||||||
|
imageMd5={data.thumbMd5}
|
||||||
|
imageDatName={data.thumbDatName}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isThumb
|
||||||
|
fallbackUrl={data.iconUrl}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data.iconUrl ? (
|
||||||
|
<img
|
||||||
|
className="mini-program-icon"
|
||||||
|
src={data.iconUrl}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="mini-program-footer">
|
||||||
|
<span aria-hidden>⌁</span>
|
||||||
|
<span>{data.appName || '小程序'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedPacketBubble({
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'redPacket' }>
|
||||||
|
}): JSX.Element {
|
||||||
|
const handleClick = (): void => {
|
||||||
|
if (data.url) window.open(data.url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`red-packet-message ${data.url ? 'is-clickable' : ''}`}
|
||||||
|
onClick={data.url ? handleClick : undefined}
|
||||||
|
>
|
||||||
|
<div className="red-packet-main">
|
||||||
|
<span className="red-packet-icon" aria-hidden>
|
||||||
|
¥
|
||||||
|
</span>
|
||||||
|
<span className="red-packet-copy">
|
||||||
|
<strong>{data.title}</strong>
|
||||||
|
<small>{data.description || '恭喜发财,大吉大利'}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="red-packet-footer">微信红包</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
||||||
const { status, roomType, duration } = data
|
const { status, roomType, duration } = data
|
||||||
const isVideo = roomType === 1
|
const isVideo = roomType === 1
|
||||||
@@ -201,7 +295,15 @@ function StickerBubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
|
function QuoteBubble({
|
||||||
|
data,
|
||||||
|
sessionId,
|
||||||
|
onImageClick
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'quote' }>
|
||||||
|
sessionId?: string
|
||||||
|
onImageClick?: (imageUrl: string) => void
|
||||||
|
}): JSX.Element {
|
||||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||||
const replyText = data.content || data.title || ''
|
const replyText = data.content || data.title || ''
|
||||||
const quotedSender = data.quotedSender || data.sender || ''
|
const quotedSender = data.quotedSender || data.sender || ''
|
||||||
@@ -210,7 +312,17 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
|
|||||||
<div className="quote-message">
|
<div className="quote-message">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||||
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
{data.quotedImageMd5 || data.quotedImageDatName ? (
|
||||||
|
<ImageBubble
|
||||||
|
imageMd5={data.quotedImageMd5}
|
||||||
|
imageDatName={data.quotedImageDatName}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isThumb
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
|
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
interface VideoBubbleProps {
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoBubble({
|
||||||
|
md5,
|
||||||
|
newMd5,
|
||||||
|
rawMd5,
|
||||||
|
duration
|
||||||
|
}: VideoBubbleProps): React.ReactElement {
|
||||||
|
const hashes = useMemo(
|
||||||
|
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
|
||||||
|
[md5, newMd5, rawMd5]
|
||||||
|
)
|
||||||
|
const [media, setMedia] = useState<{ url?: string; poster?: string; error?: string }>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
window.api
|
||||||
|
.getVideo(hashes)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) setMedia({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [hashes])
|
||||||
|
|
||||||
|
if (!media.url) {
|
||||||
|
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="video-message">
|
||||||
|
<video
|
||||||
|
className="video-content"
|
||||||
|
src={media.url}
|
||||||
|
poster={media.poster}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
{duration ? <span className="video-duration">{duration} 秒</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,38 +1,31 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||||
import { ExportMenu, ExportRange } from './ExportMenu'
|
|
||||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
dateRangeLabel: string
|
|
||||||
loadedCount: number
|
loadedCount: number
|
||||||
filteredCount: number
|
filteredCount: number
|
||||||
contentFilter: string
|
contentFilter: string
|
||||||
isAiLoading: boolean
|
isAiLoading: boolean
|
||||||
canExport: boolean
|
|
||||||
onContentFilterChange: (value: string) => void
|
onContentFilterChange: (value: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
onExport: (range: ExportRange) => void
|
|
||||||
onOpenAiSettings: () => void
|
onOpenAiSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
contact,
|
contact,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
dateRangeLabel,
|
|
||||||
loadedCount,
|
loadedCount,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
isAiLoading,
|
isAiLoading,
|
||||||
canExport,
|
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
onExport,
|
|
||||||
onOpenAiSettings
|
onOpenAiSettings
|
||||||
}: ChatHeaderProps): React.ReactElement {
|
}: ChatHeaderProps): React.ReactElement {
|
||||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||||
@@ -70,7 +63,6 @@ export function ChatHeader({
|
|||||||
<h2>{displayName}</h2>
|
<h2>{displayName}</h2>
|
||||||
<div className="chat-title-meta">
|
<div className="chat-title-meta">
|
||||||
<span>{typeLabel}</span>
|
<span>{typeLabel}</span>
|
||||||
<span>{dateRangeLabel}</span>
|
|
||||||
<span>{visibleCount} 条消息</span>
|
<span>{visibleCount} 条消息</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +88,6 @@ export function ChatHeader({
|
|||||||
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
|
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</button>
|
</button>
|
||||||
<ExportMenu disabled={!canExport} onExport={onExport} />
|
|
||||||
<div className="chat-menu" ref={moreRef}>
|
<div className="chat-menu" ref={moreRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { RefreshIcon } from './icons'
|
||||||
|
|
||||||
interface ChatStatusBarProps {
|
interface ChatStatusBarProps {
|
||||||
count: number
|
count: number
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
isAtLatest: boolean
|
isAtLatest: boolean
|
||||||
|
isReloadingAvatars: boolean
|
||||||
onShowAvatarChange: (show: boolean) => void
|
onShowAvatarChange: (show: boolean) => void
|
||||||
|
onReloadAvatars: () => void
|
||||||
onJumpToLatest: () => void
|
onJumpToLatest: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +15,9 @@ export function ChatStatusBar({
|
|||||||
count,
|
count,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
isAtLatest,
|
isAtLatest,
|
||||||
|
isReloadingAvatars,
|
||||||
onShowAvatarChange,
|
onShowAvatarChange,
|
||||||
|
onReloadAvatars,
|
||||||
onJumpToLatest
|
onJumpToLatest
|
||||||
}: ChatStatusBarProps): React.ReactElement {
|
}: ChatStatusBarProps): React.ReactElement {
|
||||||
const jumpDisabled = isAtLatest || count === 0
|
const jumpDisabled = isAtLatest || count === 0
|
||||||
@@ -29,6 +34,22 @@ export function ChatStatusBar({
|
|||||||
/>
|
/>
|
||||||
<span>显示头像</span>
|
<span>显示头像</span>
|
||||||
</label>
|
</label>
|
||||||
|
<span
|
||||||
|
className="chat-avatar-note"
|
||||||
|
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
|
||||||
|
>
|
||||||
|
头像或名称缺失时,请先在微信中进入该聊天
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-avatar-reload"
|
||||||
|
onClick={onReloadAvatars}
|
||||||
|
disabled={isReloadingAvatars}
|
||||||
|
title="尝试重新加载当前聊天的头像和名称"
|
||||||
|
>
|
||||||
|
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
|
||||||
|
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
|
||||||
|
</button>
|
||||||
<span className="chat-status-separator" aria-hidden />
|
<span className="chat-status-separator" aria-hidden />
|
||||||
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
||||||
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types'
|
|||||||
import { ImageBubble } from '../ImageBubble'
|
import { ImageBubble } from '../ImageBubble'
|
||||||
import { RichMessageBubble } from '../RichMessageBubble'
|
import { RichMessageBubble } from '../RichMessageBubble'
|
||||||
import { VoicePlayer } from '../VoicePlayer'
|
import { VoicePlayer } from '../VoicePlayer'
|
||||||
|
import { VideoBubble } from '../VideoBubble'
|
||||||
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
||||||
import { formatMessageTime } from './messageGrouping'
|
import { formatMessageTime } from './messageGrouping'
|
||||||
|
|
||||||
@@ -13,9 +14,25 @@ interface MessageBubbleProps {
|
|||||||
isMine: boolean
|
isMine: boolean
|
||||||
showAvatarSpace: boolean
|
showAvatarSpace: boolean
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
isJumpTarget?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
|
const RICH_MESSAGE_TYPES = [
|
||||||
|
'名片',
|
||||||
|
'位置',
|
||||||
|
'分享消息',
|
||||||
|
'小程序',
|
||||||
|
'微信红包',
|
||||||
|
'公众号链接',
|
||||||
|
'文件',
|
||||||
|
'文件发送中',
|
||||||
|
'视频号',
|
||||||
|
'转账',
|
||||||
|
'引用消息',
|
||||||
|
'通话',
|
||||||
|
'表情包',
|
||||||
|
'系统消息'
|
||||||
|
]
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
@@ -23,19 +40,23 @@ export function MessageBubble({
|
|||||||
isGroupChat,
|
isGroupChat,
|
||||||
isMine,
|
isMine,
|
||||||
showAvatarSpace,
|
showAvatarSpace,
|
||||||
onImageClick
|
onImageClick,
|
||||||
|
isJumpTarget
|
||||||
}: MessageBubbleProps): React.ReactElement {
|
}: MessageBubbleProps): React.ReactElement {
|
||||||
const isVoice = message.type === '语音'
|
const isVoice = message.type === '语音'
|
||||||
const isImage = message.type === '图片'
|
const isImage = message.type === '图片'
|
||||||
|
const isVideo = message.type === '视频'
|
||||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||||
const hoverTime = formatMessageTime(message)
|
const hoverTime = formatMessageTime(message)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
|
<div
|
||||||
|
className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'} ${isJumpTarget ? 'archive-jump-message' : ''}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||||
isImage ? 'image-message-bubble' : ''
|
isImage ? 'image-message-bubble' : ''
|
||||||
}`}
|
} ${isVideo ? 'video-message-bubble' : ''}`}
|
||||||
>
|
>
|
||||||
{isVoice && message.sessionId ? (
|
{isVoice && message.sessionId ? (
|
||||||
<VoicePlayer
|
<VoicePlayer
|
||||||
@@ -50,12 +71,28 @@ export function MessageBubble({
|
|||||||
sessionId={message.sessionId}
|
sessionId={message.sessionId}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
/>
|
/>
|
||||||
|
) : isVideo && message.contentData && message.contentData.type === 'video' ? (
|
||||||
|
<VideoBubble
|
||||||
|
md5={message.contentData.md5}
|
||||||
|
newMd5={message.contentData.newMd5}
|
||||||
|
rawMd5={message.contentData.rawMd5}
|
||||||
|
duration={message.contentData.duration}
|
||||||
|
/>
|
||||||
) : isRichMedia && message.contentData ? (
|
) : isRichMedia && message.contentData ? (
|
||||||
<RichMessageBubble contentData={message.contentData} />
|
<RichMessageBubble
|
||||||
|
contentData={message.contentData}
|
||||||
|
sessionId={message.sessionId}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{message.recalled && (
|
||||||
|
<span className="message-recalled-status" title={message.recalledBy || undefined}>
|
||||||
|
消息已撤回
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="message-hover-time">{hoverTime}</span>
|
<span className="message-hover-time">{hoverTime}</span>
|
||||||
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
||||||
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface MessageGroupProps {
|
|||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
jumpTargetMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
@@ -16,7 +17,8 @@ export function MessageGroup({
|
|||||||
contact,
|
contact,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
onImageClick
|
onImageClick,
|
||||||
|
jumpTargetMessageId
|
||||||
}: MessageGroupProps): React.ReactElement {
|
}: MessageGroupProps): React.ReactElement {
|
||||||
const firstMessage = group.messages[0]
|
const firstMessage = group.messages[0]
|
||||||
|
|
||||||
@@ -57,9 +59,7 @@ export function MessageGroup({
|
|||||||
)}
|
)}
|
||||||
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||||
<div className="message-stack">
|
<div className="message-stack">
|
||||||
{!isMine && isGroupChat && shouldShowAvatar && (
|
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||||
<div className="message-sender-name">{displayName}</div>
|
|
||||||
)}
|
|
||||||
{group.messages.map((message, index) => (
|
{group.messages.map((message, index) => (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -69,6 +69,7 @@ export function MessageGroup({
|
|||||||
isMine={isMine}
|
isMine={isMine}
|
||||||
showAvatarSpace={index === 0}
|
showAvatarSpace={index === 0}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
|
isJumpTarget={message.id === jumpTargetMessageId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact, Message } from '../../../../shared/types'
|
import { Contact, Message } from '../../../../shared/types'
|
||||||
import { MessageGroup } from './MessageGroup'
|
import { MessageGroup } from './MessageGroup'
|
||||||
import { buildMessageGroups } from './messageGrouping'
|
import { buildMessageGroups } from './messageGrouping'
|
||||||
@@ -13,7 +14,9 @@ interface MessageListProps {
|
|||||||
listRef: React.RefObject<HTMLDivElement | null>
|
listRef: React.RefObject<HTMLDivElement | null>
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>
|
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||||
|
onReachTop?: () => Promise<void>
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
jumpToTime?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
@@ -26,12 +29,95 @@ export function MessageList({
|
|||||||
listRef,
|
listRef,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
onScroll,
|
onScroll,
|
||||||
onImageClick
|
onReachTop,
|
||||||
|
onImageClick,
|
||||||
|
jumpToTime
|
||||||
}: MessageListProps): React.ReactElement {
|
}: MessageListProps): React.ReactElement {
|
||||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||||
|
const groupsRef = React.useRef(groups)
|
||||||
|
const loadingOlderRef = React.useRef(false)
|
||||||
|
groupsRef.current = groups
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: groups.length,
|
||||||
|
getScrollElement: () => listRef.current,
|
||||||
|
estimateSize: () => 96,
|
||||||
|
getItemKey: (index) => groups[index]?.id || index,
|
||||||
|
overscan: 8
|
||||||
|
})
|
||||||
|
const virtualItems = virtualizer.getVirtualItems()
|
||||||
|
const jumpTarget = React.useMemo(() => {
|
||||||
|
if (jumpToTime === undefined || jumpToTime === null) return null
|
||||||
|
const groupIndex = groups.findIndex((group) =>
|
||||||
|
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
|
||||||
|
)
|
||||||
|
if (groupIndex < 0) return null
|
||||||
|
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
|
||||||
|
return { groupIndex, messageId: message?.id }
|
||||||
|
}, [groups, jumpToTime])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!jumpTarget) return
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
virtualizer.scrollToIndex(jumpTarget.groupIndex, { align: 'center' })
|
||||||
|
})
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [jumpTarget, virtualizer])
|
||||||
|
|
||||||
|
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
|
onScroll(event)
|
||||||
|
const scrollElement = event.currentTarget
|
||||||
|
if (
|
||||||
|
(jumpToTime !== undefined && jumpToTime !== null) ||
|
||||||
|
scrollElement.scrollTop >= 48 ||
|
||||||
|
loadingOlderRef.current ||
|
||||||
|
isLoadingMessages ||
|
||||||
|
!onReachTop
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingOlderRef.current = true
|
||||||
|
const previousGroupCount = groups.length
|
||||||
|
const previousScrollTop = scrollElement.scrollTop
|
||||||
|
const previousScrollHeight = scrollElement.scrollHeight
|
||||||
|
const anchorMessageId = groups[0]?.messages[0]?.id
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await onReachTop()
|
||||||
|
for (let frame = 0; frame < 8; frame += 1) {
|
||||||
|
if (groupsRef.current.length > previousGroupCount) break
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
if (!anchorMessageId) return
|
||||||
|
const anchorIndex = groupsRef.current.findIndex((group) =>
|
||||||
|
group.messages.some((message) => message.id === anchorMessageId)
|
||||||
|
)
|
||||||
|
if (anchorIndex <= 0) return
|
||||||
|
virtualizer.measure()
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
|
||||||
|
if (addedHeight > 0) {
|
||||||
|
scrollElement.scrollTop = previousScrollTop + addedHeight
|
||||||
|
} else {
|
||||||
|
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable-height groups can finish measuring one frame later. Preserve
|
||||||
|
// the same message anchor after that final measurement as well.
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
if (scrollElement.scrollTop < 48) {
|
||||||
|
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
loadingOlderRef.current = false
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
||||||
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
||||||
{hiddenMessageCount > 0 && (
|
{hiddenMessageCount > 0 && (
|
||||||
<div className="wechat-system-message-row">
|
<div className="wechat-system-message-row">
|
||||||
@@ -40,16 +126,30 @@ export function MessageList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groups.map((group) => (
|
<div className="virtual-message-list" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
<MessageGroup
|
{virtualItems.map((virtualItem) => {
|
||||||
key={group.id}
|
const group = groups[virtualItem.index]
|
||||||
group={group}
|
if (!group) return null
|
||||||
contact={contact}
|
return (
|
||||||
isGroupChat={isGroupChat}
|
<div
|
||||||
showAvatar={showAvatar}
|
key={virtualItem.key}
|
||||||
onImageClick={onImageClick}
|
ref={virtualizer.measureElement}
|
||||||
/>
|
data-index={virtualItem.index}
|
||||||
))}
|
className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`}
|
||||||
|
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||||
|
>
|
||||||
|
<MessageGroup
|
||||||
|
group={group}
|
||||||
|
contact={contact}
|
||||||
|
isGroupChat={isGroupChat}
|
||||||
|
showAvatar={showAvatar}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
jumpTargetMessageId={jumpTarget?.messageId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { ConversationItem } from './ConversationItem'
|
import { ConversationItem } from './ConversationItem'
|
||||||
|
|
||||||
@@ -29,6 +30,31 @@ export function ConversationSection({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onSelectContact
|
onSelectContact
|
||||||
}: ConversationSectionProps): React.ReactElement {
|
}: ConversationSectionProps): React.ReactElement {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [scrollMargin, setScrollMargin] = useState(0)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const updateMargin = (): void => {
|
||||||
|
const list = listRef.current
|
||||||
|
const scrollElement = document.querySelector<HTMLDivElement>('.conversation-list')
|
||||||
|
if (!list || !scrollElement) return
|
||||||
|
setScrollMargin(list.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top)
|
||||||
|
}
|
||||||
|
updateMargin()
|
||||||
|
window.addEventListener('resize', updateMargin)
|
||||||
|
return () => window.removeEventListener('resize', updateMargin)
|
||||||
|
}, [contacts.length, expanded])
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: expanded ? contacts.length : 0,
|
||||||
|
getScrollElement: () => document.querySelector<HTMLDivElement>('.conversation-list'),
|
||||||
|
scrollMargin,
|
||||||
|
estimateSize: () => 58,
|
||||||
|
overscan: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) virtualizer.measure()
|
||||||
|
}, [contacts.length, expanded, virtualizer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="conversation-section">
|
<section className="conversation-section">
|
||||||
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
||||||
@@ -40,16 +66,29 @@ export function ConversationSection({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="conversation-section-list">
|
<div ref={listRef} className="conversation-section-list">
|
||||||
{contacts.length > 0 ? (
|
{contacts.length > 0 ? (
|
||||||
contacts.map((contact) => (
|
<div
|
||||||
<ConversationItem
|
className="conversation-section-virtual-content"
|
||||||
key={contact.md5}
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
contact={contact}
|
>
|
||||||
active={selectedContact?.md5 === contact.md5}
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
onSelect={onSelectContact}
|
const contact = contacts[virtualItem.index]
|
||||||
/>
|
return (
|
||||||
))
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
className="conversation-section-virtual-row"
|
||||||
|
style={{ transform: `translateY(${virtualItem.start - scrollMargin}px)` }}
|
||||||
|
>
|
||||||
|
<ConversationItem
|
||||||
|
contact={contact}
|
||||||
|
active={selectedContact?.md5 === contact.md5}
|
||||||
|
onSelect={onSelectContact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="conversation-section-empty">{emptyText}</div>
|
<div className="conversation-section-empty">{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { AccountSummary } from '../account/AccountSummary'
|
import { AccountSummary } from '../account/AccountSummary'
|
||||||
import { ConversationSection } from './ConversationSection'
|
import { ConversationItem } from './ConversationItem'
|
||||||
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
||||||
|
|
||||||
interface SelfInfo {
|
interface SelfInfo {
|
||||||
@@ -18,30 +19,55 @@ export interface ConversationSidebarProps {
|
|||||||
onSearch: (keyword: string) => void
|
onSearch: (keyword: string) => void
|
||||||
onContentFilter: (keyword: string) => void
|
onContentFilter: (keyword: string) => void
|
||||||
width: number
|
width: number
|
||||||
dateRange: string
|
|
||||||
onDateRangeChange: (range: string) => void
|
|
||||||
selfInfo: SelfInfo | null
|
selfInfo: SelfInfo | null
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SectionName = 'groups' | 'contacts'
|
||||||
|
type ConversationRow =
|
||||||
|
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||||
|
| { kind: 'contact'; id: string; contact: Contact }
|
||||||
|
|
||||||
export function ConversationSidebar({
|
export function ConversationSidebar({
|
||||||
contacts,
|
contacts,
|
||||||
selectedContact,
|
selectedContact,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onSearch,
|
onSearch,
|
||||||
width,
|
width,
|
||||||
dateRange,
|
|
||||||
onDateRangeChange,
|
|
||||||
selfInfo,
|
selfInfo,
|
||||||
dbReady,
|
dbReady,
|
||||||
onOpenSettings
|
onOpenSettings
|
||||||
}: ConversationSidebarProps): React.ReactElement {
|
}: ConversationSidebarProps): React.ReactElement {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups')
|
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||||
|
groups: true,
|
||||||
|
contacts: false
|
||||||
|
})
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||||
const users = contacts.filter((contact) => contact.type === 'user')
|
const users = contacts.filter((contact) => contact.type === 'user')
|
||||||
|
const rows = useMemo<ConversationRow[]>(
|
||||||
|
() => [
|
||||||
|
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
|
||||||
|
...(expandedSections.groups
|
||||||
|
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact }))
|
||||||
|
: []),
|
||||||
|
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
|
||||||
|
...(expandedSections.contacts
|
||||||
|
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
|
||||||
|
: [])
|
||||||
|
],
|
||||||
|
[expandedSections.contacts, expandedSections.groups, groups, users]
|
||||||
|
)
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => listRef.current,
|
||||||
|
estimateSize: (index) => (rows[index]?.kind === 'header' ? 38 : 58),
|
||||||
|
getItemKey: (index) => rows[index]?.id || index,
|
||||||
|
overscan: 10
|
||||||
|
})
|
||||||
|
|
||||||
const handleSearchChange = (term: string): void => {
|
const handleSearchChange = (term: string): void => {
|
||||||
setSearchTerm(term)
|
setSearchTerm(term)
|
||||||
@@ -53,29 +79,49 @@ export function ConversationSidebar({
|
|||||||
<ConversationSidebarHeader
|
<ConversationSidebarHeader
|
||||||
totalCount={contacts.length}
|
totalCount={contacts.length}
|
||||||
searchValue={searchTerm}
|
searchValue={searchTerm}
|
||||||
dateRange={dateRange}
|
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onDateRangeChange={onDateRangeChange}
|
|
||||||
/>
|
/>
|
||||||
<div className="conversation-list" aria-label="会话列表">
|
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||||
<ConversationSection
|
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
title="群聊"
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
contacts={groups}
|
const row = rows[virtualItem.index]
|
||||||
expanded={expandedSection === 'groups'}
|
if (!row) return null
|
||||||
selectedContact={selectedContact}
|
if (row.kind === 'header') {
|
||||||
emptyText="暂无群聊"
|
const expanded = expandedSections[row.section]
|
||||||
onToggle={() => setExpandedSection('groups')}
|
return (
|
||||||
onSelectContact={onSelectContact}
|
<button
|
||||||
/>
|
key={virtualItem.key}
|
||||||
<ConversationSection
|
type="button"
|
||||||
title="联系人"
|
className="conversation-section-header conversation-virtual-row"
|
||||||
contacts={users}
|
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||||
expanded={expandedSection === 'contacts'}
|
onClick={() =>
|
||||||
selectedContact={selectedContact}
|
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
|
||||||
emptyText="暂无联系人"
|
}
|
||||||
onToggle={() => setExpandedSection('contacts')}
|
>
|
||||||
onSelectContact={onSelectContact}
|
<span className="conversation-section-chevron" aria-hidden="true">
|
||||||
/>
|
<svg viewBox="0 0 16 16" focusable="false">
|
||||||
|
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="conversation-section-title">{row.title} ({row.count})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
className="conversation-virtual-row"
|
||||||
|
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||||
|
>
|
||||||
|
<ConversationItem
|
||||||
|
contact={row.contact}
|
||||||
|
active={selectedContact?.md5 === row.contact.md5}
|
||||||
|
onSelect={onSelectContact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="conversation-sidebar-account">
|
<div className="conversation-sidebar-account">
|
||||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ConversationSearch } from './ConversationSearch'
|
import { ConversationSearch } from './ConversationSearch'
|
||||||
import { DateRangeSelector } from './DateRangeSelector'
|
|
||||||
|
|
||||||
interface ConversationSidebarHeaderProps {
|
interface ConversationSidebarHeaderProps {
|
||||||
totalCount: number
|
totalCount: number
|
||||||
searchValue: string
|
searchValue: string
|
||||||
dateRange: string
|
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
onDateRangeChange: (range: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationSidebarHeader({
|
export function ConversationSidebarHeader({
|
||||||
totalCount,
|
totalCount,
|
||||||
searchValue,
|
searchValue,
|
||||||
dateRange,
|
onSearchChange
|
||||||
onSearchChange,
|
|
||||||
onDateRangeChange
|
|
||||||
}: ConversationSidebarHeaderProps): React.ReactElement {
|
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="conversation-sidebar-header">
|
<div className="conversation-sidebar-header">
|
||||||
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
|
|||||||
<span>{totalCount} 个会话</span>
|
<span>{totalCount} 个会话</span>
|
||||||
</div>
|
</div>
|
||||||
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||||
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
interface DateRangeSelectorProps {
|
|
||||||
value: string
|
|
||||||
onChange: (range: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATE_RANGE_OPTIONS = [
|
|
||||||
{ key: 'today', label: '今天' },
|
|
||||||
{ key: 'yesterday', label: '昨日' },
|
|
||||||
{ key: '7', label: '7 天' },
|
|
||||||
{ key: '30', label: '30 天' },
|
|
||||||
{ key: 'all', label: '全部' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="conversation-date-range" aria-label="时间范围">
|
|
||||||
{DATE_RANGE_OPTIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
type="button"
|
|
||||||
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
|
|
||||||
onClick={() => onChange(item.key)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { Contact, SelfInfo } from './exportTypes'
|
||||||
|
import { displayName } from './exportUtils'
|
||||||
|
|
||||||
|
interface ExportContactPanelProps {
|
||||||
|
contacts: Contact[]
|
||||||
|
filteredContacts: Contact[]
|
||||||
|
activeContact: Contact | null
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
dbReady: boolean
|
||||||
|
contactFilter: string
|
||||||
|
contactType: 'all' | 'group' | 'user'
|
||||||
|
onContactFilterChange: (value: string) => void
|
||||||
|
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportContactPanel({
|
||||||
|
contacts,
|
||||||
|
filteredContacts,
|
||||||
|
activeContact,
|
||||||
|
selfInfo,
|
||||||
|
dbReady,
|
||||||
|
contactFilter,
|
||||||
|
contactType,
|
||||||
|
onContactFilterChange,
|
||||||
|
onContactTypeChange,
|
||||||
|
onSelectContact,
|
||||||
|
onOpenSettings
|
||||||
|
}: ExportContactPanelProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<aside className="export-contact-panel">
|
||||||
|
<div className="export-panel-header">
|
||||||
|
<div className="export-panel-title-row">
|
||||||
|
<h2>选择聊天</h2>
|
||||||
|
<span className="export-count-badge">共 {contacts.length.toLocaleString()} 个</span>
|
||||||
|
</div>
|
||||||
|
<label className="export-search-field">
|
||||||
|
<span aria-hidden>⌕</span>
|
||||||
|
<input
|
||||||
|
value={contactFilter}
|
||||||
|
onChange={(event) => onContactFilterChange(event.target.value)}
|
||||||
|
placeholder="搜索群聊、联系人或 wxid"
|
||||||
|
aria-label="搜索聊天"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['all', '全部'],
|
||||||
|
['group', '群聊'],
|
||||||
|
['user', '联系人']
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={contactType === value ? 'active' : ''}
|
||||||
|
onClick={() => onContactTypeChange(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-contact-list">
|
||||||
|
{filteredContacts.map((contact) => {
|
||||||
|
const name = displayName(contact)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={contact.md5}
|
||||||
|
type="button"
|
||||||
|
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectContact(contact)}
|
||||||
|
>
|
||||||
|
<span className="export-contact-avatar">
|
||||||
|
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="export-contact-copy">
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
|
||||||
|
<span className="export-account-avatar">
|
||||||
|
{selfInfo?.avatar ? (
|
||||||
|
<img src={selfInfo.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
(selfInfo?.nickname || '我').slice(0, 1)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{selfInfo?.nickname || '当前账号'}</strong>
|
||||||
|
<small className={dbReady ? 'ready' : ''}>
|
||||||
|
{dbReady ? '数据库已连接' : '数据库未连接'}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { Message } from './exportTypes'
|
||||||
|
import type { ExportJobProgress, ExportStatus, SelfInfo } from './exportTypes'
|
||||||
|
import { formatPreviewTime } from './exportUtils'
|
||||||
|
|
||||||
|
interface ExportPreviewPanelProps {
|
||||||
|
status: ExportStatus
|
||||||
|
previewItems: Message[]
|
||||||
|
previewMediaCount: number
|
||||||
|
previewBytes: number
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
progress: ExportJobProgress | null
|
||||||
|
jobId: string
|
||||||
|
onCancel: (jobId: string) => void
|
||||||
|
onReveal: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportPreviewPanel({
|
||||||
|
status,
|
||||||
|
previewItems,
|
||||||
|
previewMediaCount,
|
||||||
|
previewBytes,
|
||||||
|
selfInfo,
|
||||||
|
progress,
|
||||||
|
jobId,
|
||||||
|
onCancel,
|
||||||
|
onReveal
|
||||||
|
}: ExportPreviewPanelProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
||||||
|
{status === 'idle' && (
|
||||||
|
<>
|
||||||
|
<div className="export-preview-heading">
|
||||||
|
<strong>导出预览</strong>
|
||||||
|
<span>仅预览最近 20 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="export-message-preview">
|
||||||
|
<div className="export-preview-date">最近消息</div>
|
||||||
|
{(previewItems.length
|
||||||
|
? previewItems
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'empty',
|
||||||
|
from: 'user',
|
||||||
|
content: '导出预览将在这里显示',
|
||||||
|
type: '文字',
|
||||||
|
datetime: '',
|
||||||
|
isSender: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
).map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
|
||||||
|
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="export-preview-avatar">
|
||||||
|
{message.img || (message.isSender && selfInfo?.avatar) ? (
|
||||||
|
<img src={message.img || selfInfo?.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
(message.isSender ? '我' : message.name || '友').slice(0, 1)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="export-preview-bubble">
|
||||||
|
<small>
|
||||||
|
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
|
||||||
|
{formatPreviewTime(message)}
|
||||||
|
</small>
|
||||||
|
{message.content || `[${message.type}]`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-preview-stats export-preview-real-stats">
|
||||||
|
<span>
|
||||||
|
预览消息<strong>{previewItems.length}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
媒体预览<strong>{previewMediaCount}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
预估文本大小
|
||||||
|
<strong>
|
||||||
|
{previewBytes < 1024
|
||||||
|
? `${previewBytes} B`
|
||||||
|
: `${(previewBytes / 1024).toFixed(1)} KB`}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="export-preview-stats">
|
||||||
|
<span>
|
||||||
|
消息总数<strong>待统计</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
媒体文件<strong>待统计</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
预计大小<strong>待统计</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'running' && (
|
||||||
|
<div className="export-job-state">
|
||||||
|
<h2>正在导出</h2>
|
||||||
|
<p>导出任务在后台运行,不影响档案浏览。</p>
|
||||||
|
<ol>
|
||||||
|
<li className="done">准备导出</li>
|
||||||
|
<li className="current">
|
||||||
|
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
|
||||||
|
</li>
|
||||||
|
<li>解析消息内容</li>
|
||||||
|
<li>处理媒体资源</li>
|
||||||
|
<li>生成档案</li>
|
||||||
|
</ol>
|
||||||
|
<div className="export-progress-bar" aria-label="导出进度">
|
||||||
|
<span style={{ width: `${progress?.percent ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<strong>
|
||||||
|
{progress?.phase === 'writing'
|
||||||
|
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||||
|
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||||
|
</strong>
|
||||||
|
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
|
||||||
|
取消导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'completed' && (
|
||||||
|
<div className="export-job-state completed">
|
||||||
|
<div className="export-success-icon">✓</div>
|
||||||
|
<h2>导出完成</h2>
|
||||||
|
<p>聊天档案已成功保存。</p>
|
||||||
|
<div className="export-complete-summary">
|
||||||
|
<span>
|
||||||
|
导出消息<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
媒体资源<strong>按设置处理</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
输出位置<strong>已保存</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-primary-button"
|
||||||
|
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
||||||
|
>
|
||||||
|
打开档案
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-open-folder-button"
|
||||||
|
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
||||||
|
>
|
||||||
|
在文件夹中显示
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { ExportTaskRecord } from './exportTypes'
|
||||||
|
|
||||||
|
interface ExportTaskCenterProps {
|
||||||
|
open: boolean
|
||||||
|
taskCount: number
|
||||||
|
tasks: ExportTaskRecord[]
|
||||||
|
onToggle: () => void
|
||||||
|
onCancel: (jobId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportTaskCenter({
|
||||||
|
open,
|
||||||
|
taskCount,
|
||||||
|
tasks,
|
||||||
|
onToggle,
|
||||||
|
onCancel
|
||||||
|
}: ExportTaskCenterProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="export-task-center-button" onClick={onToggle}>
|
||||||
|
任务中心{taskCount > 0 ? ` (${taskCount})` : ''}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<section className="export-task-center">
|
||||||
|
<div className="export-section-heading">
|
||||||
|
<h3>导出任务</h3>
|
||||||
|
<span>{tasks.length} 条记录</span>
|
||||||
|
</div>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<p>暂无导出记录</p>
|
||||||
|
) : (
|
||||||
|
tasks.map((task) => (
|
||||||
|
<div className="export-task-row" key={task.jobId}>
|
||||||
|
<span>
|
||||||
|
<strong>{task.contactName}</strong>
|
||||||
|
<small>
|
||||||
|
{task.format.toUpperCase()} · {task.progress.phase}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span className="export-task-progress">
|
||||||
|
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
|
||||||
|
<b>{task.progress.percent ?? 0}%</b>
|
||||||
|
</span>
|
||||||
|
{task.status === 'running' && (
|
||||||
|
<button type="button" onClick={() => onCancel(task.jobId)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import type { Message } from '../../../../shared/types'
|
||||||
|
import type {
|
||||||
|
ExportJobProgress,
|
||||||
|
ExportMessageKind,
|
||||||
|
ExportNameMode
|
||||||
|
} from '../../../../shared/export'
|
||||||
|
import { ExportContactPanel } from './ExportContactPanel'
|
||||||
|
import { ExportPreviewPanel } from './ExportPreviewPanel'
|
||||||
|
import { ExportTaskCenter } from './ExportTaskCenter'
|
||||||
|
import type {
|
||||||
|
ExportFormat,
|
||||||
|
ExportRange,
|
||||||
|
ExportStatus,
|
||||||
|
ExportWorkspaceProps,
|
||||||
|
GroupMemberName
|
||||||
|
} from './exportTypes'
|
||||||
|
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
|
||||||
|
|
||||||
|
export function ExportWorkspace({
|
||||||
|
contacts,
|
||||||
|
selectedContact,
|
||||||
|
previewMessages,
|
||||||
|
selfInfo,
|
||||||
|
dbReady,
|
||||||
|
onSelectContact,
|
||||||
|
onOpenSettings,
|
||||||
|
exportTasks,
|
||||||
|
onStartExport,
|
||||||
|
onCancelExport
|
||||||
|
}: ExportWorkspaceProps): React.ReactElement {
|
||||||
|
const [contactFilter, setContactFilter] = useState('')
|
||||||
|
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||||
|
const [range, setRange] = useState<ExportRange>('today')
|
||||||
|
const [startDate, setStartDate] = useState('')
|
||||||
|
const [endDate, setEndDate] = useState('')
|
||||||
|
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
||||||
|
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
|
||||||
|
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
|
||||||
|
const [includeMedia, setIncludeMedia] = useState(true)
|
||||||
|
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||||
|
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||||
|
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||||
|
const [keepMissing, setKeepMissing] = useState(false)
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||||
|
const [zip, setZip] = useState(false)
|
||||||
|
const [fileName, setFileName] = useState('')
|
||||||
|
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||||
|
const [jobId, setJobId] = useState('')
|
||||||
|
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||||
|
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
|
||||||
|
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
const keyword = contactFilter.trim().toLowerCase()
|
||||||
|
return contacts.filter((contact) => {
|
||||||
|
if (contactType !== 'all' && contact.type !== contactType) return false
|
||||||
|
if (!keyword) return true
|
||||||
|
return [contact.m_nsNickName, contact.m_nsUsrName].some((value) =>
|
||||||
|
value.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [contactFilter, contactType, contacts])
|
||||||
|
|
||||||
|
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
||||||
|
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
|
||||||
|
const taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||||
|
const activeName = displayName(activeContact)
|
||||||
|
const preview = previewMessages.slice(-20)
|
||||||
|
const previewMediaCount = preview.filter((message) =>
|
||||||
|
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '')
|
||||||
|
).length
|
||||||
|
const previewBytes = preview.reduce(
|
||||||
|
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
||||||
|
const nameOptions: { value: ExportNameMode; label: string }[] =
|
||||||
|
activeContact?.type === 'group'
|
||||||
|
? [
|
||||||
|
{ value: 'groupNickname', label: '群昵称' },
|
||||||
|
{ value: 'remark', label: '备注' },
|
||||||
|
{ value: 'wechatNickname', label: '微信名' }
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'remark', label: '备注' },
|
||||||
|
{ value: 'wechatNickname', label: '微信名' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const nameMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.type === 'group') {
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
const value =
|
||||||
|
nameMode === 'groupNickname'
|
||||||
|
? member.groupNickname || member.nickname || member.wxid
|
||||||
|
: nameMode === 'remark'
|
||||||
|
? member.remark || member.wechatNickname || member.wxid
|
||||||
|
: member.wechatNickname || member.wxid
|
||||||
|
map[member.wxid] = value
|
||||||
|
}
|
||||||
|
} else if (activeContact) {
|
||||||
|
map[activeContact.m_nsUsrName] =
|
||||||
|
nameMode === 'remark'
|
||||||
|
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
|
||||||
|
: activeContact.wechatNickname || activeContact.m_nsUsrName
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||||
|
return map
|
||||||
|
}, [activeContact, groupMembers, nameMode, selfInfo])
|
||||||
|
|
||||||
|
const avatarUrls = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.m_nsUsrName && activeContact.avatar) {
|
||||||
|
map[activeContact.m_nsUsrName] = activeContact.avatar
|
||||||
|
}
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
if (member.avatar) map[member.wxid] = member.avatar
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
|
||||||
|
return map
|
||||||
|
}, [activeContact, groupMembers, selfInfo])
|
||||||
|
|
||||||
|
const previewName = (message: Message): string =>
|
||||||
|
(message.senderId && nameMap[message.senderId]) ||
|
||||||
|
message.name ||
|
||||||
|
(message.isSender ? selfInfo?.nickname : undefined) ||
|
||||||
|
(message.isSender ? '我' : '联系人')
|
||||||
|
const previewAvatar = (message: Message): string | undefined =>
|
||||||
|
(message.senderId && avatarUrls[message.senderId]) ||
|
||||||
|
message.img ||
|
||||||
|
(message.isSender ? selfInfo?.avatar : undefined)
|
||||||
|
const previewItems = preview.map((message) => ({
|
||||||
|
...message,
|
||||||
|
name: previewName(message),
|
||||||
|
img: previewAvatar(message)
|
||||||
|
}))
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||||
|
setGroupMembers([])
|
||||||
|
if (!activeContact || activeContact.type !== 'group') return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
||||||
|
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
||||||
|
})
|
||||||
|
}, 300)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [activeContact])
|
||||||
|
|
||||||
|
const toggleKind = (value: string): void => {
|
||||||
|
setSelectedKinds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(value)) next.delete(value)
|
||||||
|
else next.add(value)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = async (): Promise<void> => {
|
||||||
|
if (!activeContact || status === 'running') return
|
||||||
|
const nextJobId = `export-${Date.now()}`
|
||||||
|
setJobId(nextJobId)
|
||||||
|
setProgress(null)
|
||||||
|
setStatus('running')
|
||||||
|
let exportNameMap = nameMap
|
||||||
|
let exportAvatarUrls = avatarUrls
|
||||||
|
if (activeContact.type === 'group') {
|
||||||
|
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
|
||||||
|
const members = (snapshot?.members || []) as GroupMemberName[]
|
||||||
|
setGroupMembers(members)
|
||||||
|
exportNameMap = { ...nameMap }
|
||||||
|
exportAvatarUrls = { ...avatarUrls }
|
||||||
|
for (const member of members) {
|
||||||
|
exportNameMap[member.wxid] =
|
||||||
|
nameMode === 'groupNickname'
|
||||||
|
? member.groupNickname || member.nickname || member.wxid
|
||||||
|
: nameMode === 'remark'
|
||||||
|
? member.remark || member.wechatNickname || member.wxid
|
||||||
|
: member.wechatNickname || member.wxid
|
||||||
|
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
|
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
|
||||||
|
const startOfRange = days
|
||||||
|
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
|
||||||
|
: null
|
||||||
|
const request = {
|
||||||
|
jobId: nextJobId,
|
||||||
|
userMd5: activeContact.md5,
|
||||||
|
name: activeName,
|
||||||
|
format,
|
||||||
|
outputName,
|
||||||
|
startTime: startOfRange
|
||||||
|
? Math.floor(startOfRange.getTime() / 1000)
|
||||||
|
: range === 'custom' && startDate
|
||||||
|
? Math.floor(new Date(startDate).getTime() / 1000)
|
||||||
|
: undefined,
|
||||||
|
endTime: startOfRange
|
||||||
|
? Math.floor(endOfToday.getTime() / 1000)
|
||||||
|
: range === 'custom' && endDate
|
||||||
|
? Math.floor(new Date(endDate).getTime() / 1000)
|
||||||
|
: undefined,
|
||||||
|
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||||
|
includeMedia,
|
||||||
|
includeAvatars,
|
||||||
|
avatarUrls: exportAvatarUrls,
|
||||||
|
nameMode,
|
||||||
|
nameMap: exportNameMap,
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
const result = await onStartExport(request)
|
||||||
|
if (result.success) {
|
||||||
|
setProgress((current) => ({
|
||||||
|
...(current || { jobId: nextJobId, processed: result.messageCount || 0 }),
|
||||||
|
phase: 'completed',
|
||||||
|
processed: result.messageCount ?? current?.processed ?? 0,
|
||||||
|
total: result.messageCount ?? current?.total,
|
||||||
|
percent: 100,
|
||||||
|
outputPath: result.outputPath
|
||||||
|
}))
|
||||||
|
setStatus('completed')
|
||||||
|
} else if (result.error !== '已取消') {
|
||||||
|
setStatus('idle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() =>
|
||||||
|
window.api.onExportProgress((next) => {
|
||||||
|
if (next.jobId !== jobId) return
|
||||||
|
setProgress(next)
|
||||||
|
if (next.phase === 'completed') setStatus('completed')
|
||||||
|
if (next.phase === 'cancelled' || next.phase === 'failed') setStatus('idle')
|
||||||
|
}),
|
||||||
|
[jobId]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentTask) return
|
||||||
|
setJobId(currentTask.jobId)
|
||||||
|
setProgress(currentTask.progress)
|
||||||
|
setStatus(
|
||||||
|
currentTask.status === 'running'
|
||||||
|
? 'running'
|
||||||
|
: currentTask.status === 'completed'
|
||||||
|
? 'completed'
|
||||||
|
: 'idle'
|
||||||
|
)
|
||||||
|
}, [currentTask])
|
||||||
|
|
||||||
|
const targetPath =
|
||||||
|
format === 'html'
|
||||||
|
? zip
|
||||||
|
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
||||||
|
: `文稿/WechatExplorer/导出/${outputName}/`
|
||||||
|
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="export-workspace">
|
||||||
|
<ExportContactPanel
|
||||||
|
contacts={contacts}
|
||||||
|
filteredContacts={filteredContacts}
|
||||||
|
activeContact={activeContact}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
dbReady={dbReady}
|
||||||
|
contactFilter={contactFilter}
|
||||||
|
contactType={contactType}
|
||||||
|
onContactFilterChange={setContactFilter}
|
||||||
|
onContactTypeChange={setContactType}
|
||||||
|
onSelectContact={onSelectContact}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="export-config-panel">
|
||||||
|
<div className="export-config-scroll">
|
||||||
|
<ExportTaskCenter
|
||||||
|
open={taskCenterOpen}
|
||||||
|
taskCount={taskCount}
|
||||||
|
tasks={exportTasks}
|
||||||
|
onToggle={() => setTaskCenterOpen((open) => !open)}
|
||||||
|
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
|
||||||
|
/>
|
||||||
|
<header className="export-config-header">
|
||||||
|
<span className="export-chat-avatar">
|
||||||
|
{activeContact?.avatar ? (
|
||||||
|
<img src={activeContact.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
activeName.slice(0, 1)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<h1>导出设置</h1>
|
||||||
|
<p>
|
||||||
|
{activeName}
|
||||||
|
{activeContact?.type === 'group' ? ' · 群聊' : ''}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="export-section export-format-top">
|
||||||
|
<h3>导出格式</h3>
|
||||||
|
<div className="export-format-grid">
|
||||||
|
{formatOrder.map((value) => (
|
||||||
|
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}>
|
||||||
|
<strong>{formatLabels[value].label}</strong>
|
||||||
|
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="export-helper-text">CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。</p>
|
||||||
|
{format === 'html' && (
|
||||||
|
<div className="export-html-options">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML 资源包
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML 资源包并压缩为 ZIP
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<div className="export-section-heading">
|
||||||
|
<h3>时间范围</h3>
|
||||||
|
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="export-range-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'today' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('today')}
|
||||||
|
>
|
||||||
|
今天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'threeDays' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('threeDays')}
|
||||||
|
>
|
||||||
|
最近 3 天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'sevenDays' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('sevenDays')}
|
||||||
|
>
|
||||||
|
最近 7 天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'custom' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('custom')}
|
||||||
|
>
|
||||||
|
自定义时间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{range === 'custom' && (
|
||||||
|
<div className="export-date-fields">
|
||||||
|
<label>
|
||||||
|
开始时间
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(event) => setStartDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
结束时间
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>消息内容</h3>
|
||||||
|
<div className="export-kind-grid">
|
||||||
|
{messageKinds.map(([value, label]) => (
|
||||||
|
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value !== 'video' && selectedKinds.has(value)}
|
||||||
|
disabled={value === 'video'}
|
||||||
|
onChange={() => toggleKind(value)}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
{value === 'video' && (
|
||||||
|
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>消息显示名称</h3>
|
||||||
|
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
|
||||||
|
{nameOptions.map((option) => (
|
||||||
|
<label key={option.value} className="export-name-mode-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="export-name-mode"
|
||||||
|
checked={nameMode === option.value}
|
||||||
|
onChange={() => setNameMode(option.value)}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>资源处理</h3>
|
||||||
|
<label className="export-media-master">
|
||||||
|
<span>包含图片、视频、语音及动态表情</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeMedia}
|
||||||
|
disabled={format !== 'html'}
|
||||||
|
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
|
||||||
|
<label className="export-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferOriginal}
|
||||||
|
disabled={!includeMedia || format !== 'html'}
|
||||||
|
onChange={(event) => setPreferOriginal(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>优先导出原图</span>
|
||||||
|
</label>
|
||||||
|
<label className="export-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={fallbackThumbnail}
|
||||||
|
disabled={!includeMedia || format !== 'html'}
|
||||||
|
onChange={(event) => setFallbackThumbnail(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>原图缺失时使用缩略图</span>
|
||||||
|
</label>
|
||||||
|
<label className="export-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={keepMissing}
|
||||||
|
disabled={!includeMedia || format !== 'html'}
|
||||||
|
onChange={(event) => setKeepMissing(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>媒体缺失时保留占位说明</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="export-helper-text">资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。</p>
|
||||||
|
<div className="export-resource-statuses">
|
||||||
|
<span>图片解密:已就绪</span>
|
||||||
|
<span>视频资源:可用</span>
|
||||||
|
<span>语音资源:可用</span>
|
||||||
|
<span>表情资源:按需解析</span>
|
||||||
|
</div>
|
||||||
|
<p className="export-helper-text">媒体资源会延长导出时间,缺失资源不会中断任务。</p>
|
||||||
|
<label className="export-media-master">
|
||||||
|
<span>在聊天气泡旁显示头像</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeAvatars}
|
||||||
|
onChange={(event) => setIncludeAvatars(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>导出格式</h3>
|
||||||
|
<div className="export-format-grid">
|
||||||
|
{formatOrder.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={format === value ? 'active' : ''}
|
||||||
|
onClick={() => setFormat(value)}
|
||||||
|
>
|
||||||
|
<strong>{formatLabels[value].label}</strong>
|
||||||
|
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{format === 'html' && (
|
||||||
|
<div className="export-html-options">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="html-package"
|
||||||
|
checked={!zip}
|
||||||
|
onChange={() => setZip(false)}
|
||||||
|
/>{' '}
|
||||||
|
HTML 资源包(推荐)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="html-package"
|
||||||
|
checked={zip}
|
||||||
|
onChange={() => setZip(true)}
|
||||||
|
/>{' '}
|
||||||
|
HTML 资源包并压缩为 ZIP
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section export-save-section">
|
||||||
|
<h3>保存设置</h3>
|
||||||
|
<label>
|
||||||
|
文件名称
|
||||||
|
<input
|
||||||
|
value={fileName}
|
||||||
|
onChange={(event) => setFileName(event.target.value)}
|
||||||
|
placeholder={`${activeName}_聊天档案`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="export-target-path">
|
||||||
|
<span>保存位置</span>
|
||||||
|
<strong>{targetPath}</strong>
|
||||||
|
<button type="button">选择位置</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer className="export-action-bar">
|
||||||
|
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
|
||||||
|
<span>
|
||||||
|
{status === 'running'
|
||||||
|
? '正在后台导出'
|
||||||
|
: status === 'completed'
|
||||||
|
? '导出完成'
|
||||||
|
: '准备就绪'}
|
||||||
|
</span>
|
||||||
|
<span className="export-target-summary">路径:{targetPath}</span>
|
||||||
|
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
|
||||||
|
恢复默认
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-primary-button"
|
||||||
|
disabled={!activeContact || status === 'running'}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ExportPreviewPanel
|
||||||
|
status={status}
|
||||||
|
previewItems={previewItems}
|
||||||
|
previewMediaCount={previewMediaCount}
|
||||||
|
previewBytes={previewBytes}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
progress={progress}
|
||||||
|
jobId={jobId}
|
||||||
|
onCancel={(exportJobId) => {
|
||||||
|
void window.api.cancelExport(exportJobId)
|
||||||
|
setStatus('idle')
|
||||||
|
}}
|
||||||
|
onReveal={(path) => void window.api.revealExport(path)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
import type {
|
||||||
|
ExportJobProgress,
|
||||||
|
ExportMessageKind,
|
||||||
|
ExportNameMode,
|
||||||
|
ExportRequest,
|
||||||
|
ExportResult,
|
||||||
|
ExportTaskRecord
|
||||||
|
} from '../../../../shared/export'
|
||||||
|
|
||||||
|
export type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
|
||||||
|
export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||||
|
export type ExportStatus = 'idle' | 'running' | 'completed'
|
||||||
|
|
||||||
|
export interface GroupMemberName {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfInfo {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
accountRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportWorkspaceProps {
|
||||||
|
contacts: Contact[]
|
||||||
|
selectedContact: Contact | null
|
||||||
|
previewMessages: Message[]
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
dbReady: boolean
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
onOpenSettings: () => void
|
||||||
|
exportTasks: ExportTaskRecord[]
|
||||||
|
onStartExport: (request: ExportRequest) => Promise<ExportResult>
|
||||||
|
onCancelExport: (jobId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
import type { ExportFormat, GroupMemberName } from './exportTypes'
|
||||||
|
|
||||||
|
export const messageKinds = [
|
||||||
|
['text', '文字'],
|
||||||
|
['image', '图片'],
|
||||||
|
['video', '视频'],
|
||||||
|
['voice', '语音'],
|
||||||
|
['sticker', '表情包'],
|
||||||
|
['share', '链接与分享'],
|
||||||
|
['location', '位置'],
|
||||||
|
['system', '系统消息']
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
|
||||||
|
html: { label: 'HTML', hint: '推荐' },
|
||||||
|
csv: { label: 'CSV' },
|
||||||
|
json: { label: 'JSON' },
|
||||||
|
markdown: { label: 'Markdown' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
||||||
|
|
||||||
|
export function displayName(contact: Contact | null): string {
|
||||||
|
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPreviewTime(message: Message): string {
|
||||||
|
if (!message.createTime) return message.datetime || ''
|
||||||
|
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNameMap(
|
||||||
|
activeContact: Contact | null,
|
||||||
|
groupMembers: GroupMemberName[],
|
||||||
|
nameMode: string,
|
||||||
|
selfInfo: { wxid: string; nickname: string } | null
|
||||||
|
): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.type === 'group') {
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
const value =
|
||||||
|
nameMode === 'groupNickname'
|
||||||
|
? member.groupNickname || member.nickname || member.wxid
|
||||||
|
: nameMode === 'remark'
|
||||||
|
? member.remark || member.wechatNickname || member.wxid
|
||||||
|
: member.wechatNickname || member.wxid
|
||||||
|
map[member.wxid] = value
|
||||||
|
}
|
||||||
|
} else if (activeContact) {
|
||||||
|
map[activeContact.m_nsUsrName] =
|
||||||
|
nameMode === 'remark'
|
||||||
|
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
|
||||||
|
: activeContact.wechatNickname || activeContact.m_nsUsrName
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAvatarUrls(
|
||||||
|
activeContact: Contact | null,
|
||||||
|
groupMembers: GroupMemberName[],
|
||||||
|
selfInfo: { wxid: string; avatar?: string } | null
|
||||||
|
): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.m_nsUsrName && activeContact.avatar) {
|
||||||
|
map[activeContact.m_nsUsrName] = activeContact.avatar
|
||||||
|
}
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
if (member.avatar) map[member.wxid] = member.avatar
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
|
||||||
|
return map
|
||||||
|
}
|
||||||