Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597667e005 | ||
|
|
515348b6d8 | ||
|
|
e150605c91 | ||
|
|
556d70eab3 | ||
|
|
9bd2c4bb94 | ||
|
|
19128fccca | ||
|
|
eaf8e9b07d | ||
|
|
aebf56b3f7 | ||
|
|
ce4b00bcd9 | ||
|
|
31df97237d | ||
|
|
7b54b611d3 | ||
|
|
f1ceef0e5e | ||
|
|
ab24185670 | ||
|
|
842144eba0 | ||
|
|
d37341ab52 | ||
|
|
5d041aa137 | ||
|
|
519c10223d | ||
|
|
d28b579cb4 | ||
|
|
c291c94bd8 | ||
|
|
16a9aed6bb | ||
|
|
7880e874b2 | ||
|
|
7e217f7034 | ||
|
|
4f0b4884b6 | ||
|
|
d4f6b755e5 | ||
|
|
995a95e8dc | ||
|
|
8bb9576f10 | ||
|
|
a776830144 | ||
|
|
ad50939005 | ||
|
|
71e970c55c | ||
|
|
8d82fabf57 | ||
|
|
e8c12696b2 | ||
|
|
267730d59e | ||
|
|
3dbc11c19d | ||
|
|
d079d45279 | ||
|
|
b39cb362f7 | ||
|
|
f9e629948d | ||
|
|
a0b163823d | ||
|
|
3eb4438f4c | ||
|
|
7932ea2f54 | ||
|
|
4d14ce867c | ||
|
|
7f7d673abb | ||
|
|
4bba8bd871 | ||
|
|
30c47af1f9 | ||
|
|
77aa74dd92 | ||
|
|
f96ada906c | ||
|
|
a50e903e55 | ||
|
|
5a87a10d12 | ||
|
|
10dc74e2a1 | ||
|
|
c00d581d9e | ||
|
|
f6f8bc33f9 | ||
|
|
7196adb84c | ||
|
|
a740a50e79 | ||
|
|
ffe797b3e4 | ||
|
|
9cd6061c0f | ||
|
|
67d86f7834 | ||
|
|
a6138fd62b | ||
|
|
d1763757be | ||
|
|
a4dfa542d1 | ||
|
|
9fb1d14c41 |
@@ -1,8 +1,21 @@
|
||||
# WeChat Database Key (Optional, can be entered in UI)
|
||||
VITE_DB_KEY=
|
||||
|
||||
# DeepSeek API Key (Optional, can be entered in UI)
|
||||
VITE_DEEPSEEK_API_KEY=
|
||||
# Auto login on startup with VITE_DB_KEY or saved key.
|
||||
# Set to true/1/yes/on for local development. Default is disabled.
|
||||
VITE_AUTO_LOGIN=false
|
||||
|
||||
# Message types to filter out (comma separated)
|
||||
VITE_FILTER_MSG_TYPES=分享消息,图片,表情包,视频
|
||||
# AI API Configuration (Optional, can be entered in UI)
|
||||
VITE_DEEPSEEK_API_KEY=
|
||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
||||
VITE_AI_MODEL=deepseek-v4-flash
|
||||
|
||||
# Message types to filter out (comma separated). Empty means show all message types.
|
||||
VITE_FILTER_MSG_TYPES=
|
||||
|
||||
# Image Decryption Keys (Optional, for WeChat 4.0+ image decryption)
|
||||
# These are dev fallbacks. End users can fill or auto-fetch them in Settings.
|
||||
# XOR Key: hex format like 0x40, 0x53 etc.
|
||||
# AES Key: 16-character string, derived from wxid and code
|
||||
VITE_IMAGE_XOR_KEY=
|
||||
VITE_IMAGE_AES_KEY=
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
dist
|
||||
out
|
||||
.env
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
.omc
|
||||
.codex/
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -1,48 +1,199 @@
|
||||
# WechatExplorer
|
||||
|
||||
MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
||||
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||
|
||||
## 项目说明
|
||||
|
||||
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
|
||||
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||
- **问题与解答**: 整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云/关键词**: 可视化展示群聊关键词。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||
|
||||
## 📸 预览
|
||||
|
||||
<img src="./public/example1.png" alt="预览图1" />
|
||||
<img src="./public/example2.png" alt="预览图2" />
|
||||
<img src="./public/example3.png" alt="预览图3" />
|
||||
### 日报模板
|
||||
|
||||
<details>
|
||||
<summary>点击查看完整日报模板</summary>
|
||||
<br />
|
||||
<img src="./public/report-template-1.png" alt="完整日报模板" />
|
||||
</details>
|
||||
|
||||
### AI 群聊日报界面
|
||||
|
||||
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
||||
|
||||
### 本地 API 与 Reader Skill
|
||||
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
||||
|
||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
|
||||
## 📦 安装说明
|
||||
|
||||
1. 下载下方的 `xxx.dmg` 文件。
|
||||
### 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)
|
||||
|
||||
1. 下载 Releases 中的 `xxx.dmg` 文件。
|
||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
||||
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
|
||||
|
||||
## ✨ 功能特性
|
||||
### Windows 版本已测 4.1.9
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **AI 智能总结**: 集成 DeepSeek AI,一键总结群聊精华内容,生成话题报告。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||
> 微信 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` 安装包。
|
||||
2. 双击安装,可按安装向导选择安装目录。
|
||||
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
|
||||
|
||||
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 使用前置要求
|
||||
|
||||
- 微信>=4.0 无法使用
|
||||
- 微信<=4.0 需要获取自己微信本地数据库的密码, 获取方式参考: [Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/)
|
||||
- 如果无法获取本地数据库密码 则无法使用当前项目
|
||||
- **微信版本**:
|
||||
- 微信 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`) 和对应的密钥
|
||||
- [DeepSeek API Key](https://www.deepseek.com/) (用于 AI 总结功能)
|
||||
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
|
||||
|
||||
### 环境变量配置 (.env)
|
||||
|
||||
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | --------------------------- | --------------------------- |
|
||||
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
|
||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
|
||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
|
||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `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)
|
||||
|
||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||
|
||||
### 启用本地 API
|
||||
|
||||
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
|
||||
|
||||
1. 安装并启动 WechatExplorer
|
||||
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
|
||||
3. API 即在 `http://127.0.0.1:6131` 可用
|
||||
|
||||
### 7×24 提供 API(菜单栏常驻模式)
|
||||
|
||||
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式:
|
||||
|
||||
```bash
|
||||
# 任选一种方式
|
||||
WXE_TRAY=1 open /Applications/WechatExplorer.app
|
||||
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
|
||||
```
|
||||
|
||||
启用后:
|
||||
|
||||
- macOS dock 图标自动隐藏
|
||||
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
|
||||
- 主窗口关闭后 API 服务继续运行
|
||||
|
||||
### API 端点一览
|
||||
|
||||
| 端点 | 说明 |
|
||||
| ------------------------------------------------ | --------------------------------------- |
|
||||
| `GET /api/v1/health` | 健康检查 |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
|
||||
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
|
||||
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
|
||||
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
|
||||
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
|
||||
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 |
|
||||
|
||||
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### 安装 Reader Skill,让 Agent 读取和总结群聊
|
||||
|
||||
WechatExplorer 已内置 **Reader Skill**,无需手动复制仓库中的 `SKILL.md`:
|
||||
|
||||
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
|
||||
2. 打开应用内的 **API** 页面。
|
||||
3. 在“快速接入”中选择 **Codex** 或 **Claude Code**。
|
||||
4. 点击复制安装指令,将指令粘贴给对应的 Agent 执行。
|
||||
5. 安装完成后,可以直接向 Agent 提问:
|
||||
|
||||
> “今天技术交流群聊了什么?”
|
||||
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### curl 调试示例(可选)
|
||||
|
||||
不使用 Agent 时,也可以通过 `curl` 直接调试本地 HTTP API:
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 今天 摸鱼交流群 的聊天记录
|
||||
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
|
||||
--data-urlencode "talker=摸鱼交流群" \
|
||||
--data-urlencode "time=$(date +%Y-%m-%d)"
|
||||
|
||||
# 把群昵称解析成 md5
|
||||
curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
--data-urlencode "q=摸鱼交流群"
|
||||
```
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||
|
||||
## 🔗 参考
|
||||
## 🔗 参考致谢
|
||||
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
|
||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 获取当前版本号(从 package.json 读取)
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
|
||||
# 格式化为新的 tag(在版本号前加上 "v")
|
||||
TAG="v${VERSION}"
|
||||
|
||||
# 输出当前生成的 tag
|
||||
echo "生成的 tag: $TAG"
|
||||
|
||||
# 创建并推送 tag 到远程
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
|
||||
echo "Tag $TAG 已经推送到远程仓库!"
|
||||
@@ -0,0 +1,47 @@
|
||||
# macOS 关闭 SIP 教程
|
||||
|
||||
SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
|
||||
|
||||
## 准备
|
||||
|
||||
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
|
||||
- 需要进入 macOS 恢复模式。
|
||||
- 请先保存正在编辑的文件,并预留一次重启时间。
|
||||
|
||||
## 关闭 SIP
|
||||
|
||||
### Intel Mac
|
||||
|
||||
1. 关机。
|
||||
2. 按下开机键后,立刻按住 `Command + R`。
|
||||
3. 保持按住,直到进入 macOS 恢复模式。
|
||||
|
||||
### Apple Silicon Mac(M1/M2/M3/M4)
|
||||
|
||||
1. 关机。
|
||||
2. 长按开机键不放。
|
||||
3. 直到出现启动选项界面后松开。
|
||||
4. 选择“选项”,进入 macOS 恢复模式。
|
||||
|
||||
### 在恢复模式中执行命令
|
||||
|
||||
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**。
|
||||
2. 选择 **Terminal(终端)**。
|
||||
3. 在终端中输入:
|
||||
|
||||
```bash
|
||||
csrutil disable
|
||||
```
|
||||
|
||||
4. 按回车执行。
|
||||
5. 看到关闭成功提示后,重启电脑。
|
||||
|
||||
## 重新开启 SIP
|
||||
|
||||
如果后续不再需要关闭 SIP,建议重新进入恢复模式,在终端中执行:
|
||||
|
||||
```bash
|
||||
csrutil enable
|
||||
```
|
||||
|
||||
然后重启电脑。
|
||||
@@ -0,0 +1,355 @@
|
||||
---
|
||||
name: wechatexplorer-reader
|
||||
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app。
|
||||
---
|
||||
|
||||
# WechatExplorer Reader
|
||||
|
||||
通过本地 HTTP API(`http://127.0.0.1:6131`)读取 WechatExplorer 已经解锁的微信数据库内容。
|
||||
|
||||
## 数据源
|
||||
|
||||
- **本服务由 WechatExplorer.app 提供**,数据完全在本地处理,不会上传任何服务器
|
||||
- 用户必须在 WechatExplorer 主窗口完成**首次密钥配置**(解锁 WCDB 数据库)
|
||||
- 默认监听 `127.0.0.1:6131`,仅本机可访问,无需鉴权
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **安装并启动 WechatExplorer.app**(从项目 release 页面下载)
|
||||
2. **首次启动时完成密钥配置**:在主界面第一步输入微信数据库密钥(64 位 hex),完成 WCDB 初始化
|
||||
3. **如需 7×24 提供 API**:用 `WXE_TRAY=1` 或 `--tray` 参数启动 app,启用菜单栏常驻模式(主窗口关闭后服务仍在)
|
||||
|
||||
## API 列表
|
||||
|
||||
GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图)。所有端点返回 JSON。
|
||||
|
||||
| 端点 | 用途 | 关键参数 |
|
||||
|------|------|---------|
|
||||
| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — |
|
||||
| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) |
|
||||
| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `keyword` |
|
||||
| `GET /api/v1/recent_chat` | 最近会话 | `limit`(默认 50) |
|
||||
| `GET /api/v1/chatlog` | 聊天记录 | `talker`、`time` 或 `startTime`/`endTime` |
|
||||
| `GET /api/v1/group_snapshot` | 群成员快照 | `md5` |
|
||||
| `GET /api/v1/resolve` | 把昵称/wxid/md5 解析成 md5 | `q` |
|
||||
| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) |
|
||||
|
||||
### `talker` 参数可接受的值
|
||||
|
||||
`chatlog` 和 `recent_chat` 的 `talker` / 列表项 ID 支持以下三种形式,服务端会按 `nickname → wxid → md5` 顺序匹配:
|
||||
|
||||
1. **群昵称 / 好友备注**(模糊匹配,如 `技术交流`、`摸鱼群`)
|
||||
2. **微信 wxid**(如 `wxid_abc123`、`gh_xxxxx@chatroom`)
|
||||
3. **会话 md5**(如 `49023470180@chatroom` 的 md5 哈希,可在 `contact` 接口里看到)
|
||||
|
||||
不确定时先调 `GET /api/v1/resolve?q=<输入>` 校验,返回 `{ md5, m_nsUsrName, m_nsNickName, type, ... }`。
|
||||
|
||||
### `chatroom` 与 `contact?type=group` 字段一致性
|
||||
|
||||
`/chatroom` 和 `/contact?type=group` 返回的是**同一个集合**(都是 `listContacts().filter(type==='group')`),字段也完全一致:
|
||||
|
||||
```json
|
||||
{
|
||||
"m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker
|
||||
"m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer
|
||||
"type": "group",
|
||||
"md5": "..."
|
||||
}
|
||||
```
|
||||
|
||||
需要 `displayName` 时从 `m_nsNickName` 里解析;需要拉消息就传 `m_nsUsrName` 当 talker。
|
||||
|
||||
## 时间范围格式(`time` 参数)
|
||||
|
||||
支持以下格式:
|
||||
|
||||
| 输入 | 含义 |
|
||||
|------|------|
|
||||
| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 |
|
||||
| `2026-07-01~2026-07-03` | 日期范围(闭区间) |
|
||||
| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) |
|
||||
| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 |
|
||||
|
||||
也可以直接传 unix 秒级时间戳作为 `startTime` 和 `endTime`。
|
||||
|
||||
### "今天 / 昨天 / 本周" 的时区语义
|
||||
|
||||
所有 `time` / `startTime` / `endTime` 都按**用户本机时区**解析(由 `current_time` 里的 `timezone` 字段给出,典型为 `Asia/Shanghai`)。含义如下:
|
||||
|
||||
- "今天 2026-07-03" → 本机 2026-07-03 00:00:00 ~ 23:59:59(北京时间 24 小时),**不是** UTC 当天
|
||||
- "昨天" → 本机昨天 0 点 ~ 23:59:59
|
||||
- "本周" → 本周一 0 点 ~ 当前时刻(按本机时区所在周的周一)
|
||||
|
||||
跨时区时(如用户在国外):仍以本机时区为准,需要按 UTC 处理时显式传 unix 时间戳。
|
||||
|
||||
## 时间预检工作流(Time-Aware Workflow)
|
||||
|
||||
**重要**:只要用户请求中包含"今天"、"昨天"、"本周"、"刚才"等相对时间概念,**禁止**直接生成日期字符串。
|
||||
|
||||
**步骤 1**:先调用 `current_time` 工具获取本地 RFC3339 时间。
|
||||
**步骤 2**:根据返回的时间计算对应的 `time` 参数。
|
||||
**步骤 3**:用计算后的参数调 `chatlog`。
|
||||
|
||||
示例:
|
||||
- 用户: "今天 摸鱼交流群 聊了啥?"
|
||||
- AI: 先 `GET /api/v1/current_time` → 得到 `2026-07-03T14:30:00+08:00` → 计算 `time=2026-07-03` → `GET /api/v1/chatlog?talker=摸鱼交流群&time=2026-07-03`
|
||||
|
||||
## 多步上下文检索(强制)
|
||||
|
||||
当查询特定话题或特定发送者发言时,**必须**按以下流程操作:
|
||||
|
||||
1. **初步定位**:用 `contact` 或 `chatroom` 端点确定群聊 md5 / wxid
|
||||
2. **粗查**:用 `chatlog` + 较宽时间范围找到相关消息时间点
|
||||
3. **精查**:对每个关键时间点分别查前后 15-30 分钟(不带任何 keyword 过滤),用完整上下文分析
|
||||
|
||||
**禁止**:仅凭一次粗查结果直接回答用户。
|
||||
|
||||
## 生成群日报(POST /api/v1/report)
|
||||
|
||||
当用户希望输出**可视化群日报**(长图 PNG + HTML 邮件版)时,用这个端点。WechatExplorer 内置 `mobile_daily_report.html` 模板,渲染后会同时落盘 `htmlPath` 和 `pngPath`,并返回 `imageDataUrl` 可直接预览。
|
||||
|
||||
### 请求体(`GroupReportExportRequest`)
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"overview": "一句话总览,20-80 字",
|
||||
"topics": [
|
||||
{
|
||||
"title": "话题标题",
|
||||
"timeRange": "10:00-12:30",
|
||||
"heat": "高", // "高" | "中" | "低"
|
||||
"participants": ["张三", "李四"],
|
||||
"summary": "本话题讨论了什么",
|
||||
"conclusion": "可选,达成的结论",
|
||||
"keywords": ["关键词1", "关键词2"]
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{ "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" }
|
||||
],
|
||||
"importantMessages": [
|
||||
{ "sender": "张三", "time": "10:23", "content": "原消息文本", "note": "为什么重要" }
|
||||
],
|
||||
"quotes": [
|
||||
{
|
||||
"messages": [{ "sender": "李四", "content": "原话1" }, { "sender": "王五", "content": "原话2" }],
|
||||
"note": "为什么这些话值得引用"
|
||||
}
|
||||
],
|
||||
"qa": [
|
||||
{ "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": {
|
||||
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
||||
"activeTimeline": "10:00-12:00 为最活跃时段",
|
||||
"topSpeakers": [{ "name": "张三", "count": 58 }],
|
||||
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
|
||||
},
|
||||
"keywords": ["高频词1", "高频词2"],
|
||||
"hero": {
|
||||
"headline": "一句抓重点的日报标题",
|
||||
"summary": "一句概览",
|
||||
"keyTakeaway": "最重要结论",
|
||||
"pendingNote": "待跟进事项"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
"reportDate": "2026-07-03",
|
||||
"dateRange": "2026-07-03 全天",
|
||||
"messageCount": 1234,
|
||||
"activeUsers": 56,
|
||||
"timeSpan": "00:00-23:59",
|
||||
"generatedAt": "2026-07-03 22:00",
|
||||
"recordNote": "本日报由 WechatExplorer 自动生成",
|
||||
"footerNote": "底部附加说明",
|
||||
"heroParticipants": ["张三", "李四"],
|
||||
"avatars": {},
|
||||
"talker": "技术交流",
|
||||
"timeRange": "2026-07-03"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(`GroupReportExportResult`)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"htmlPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.html",
|
||||
"pngPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.png",
|
||||
"imageDataUrl": "data:image/png;base64,iVBORw0K..."
|
||||
}
|
||||
```
|
||||
|
||||
成功返回 200;失败返回 500 + `{ success: false, error: "..." }`。HTML 和 PNG 用 `mobile_daily_report.html` 模板渲染,长图宽度自适应移动端预览。
|
||||
|
||||
### 典型工作流
|
||||
|
||||
1. 调 `current_time` + `chatlog` 拉取当天/目标时间段消息
|
||||
2. LLM 总结生成 `report` + `metadata`(直接走 AI 总结即可,无需自己造数据)
|
||||
3. POST 到 `/api/v1/report` 拿到 `htmlPath` / `pngPath`,把文件路径告诉用户即可在 Finder 打开
|
||||
4. **不要**自己拼 HTML/PNG,模板已内置,只需组织好 report/metadata 字段
|
||||
|
||||
### 必填字段与隐式约束(踩坑提示)
|
||||
|
||||
`metadata` 的以下字段**必填**,缺一返回 500:
|
||||
|
||||
- `groupName`、`reportDate`、`dateRange`、`generatedAt`
|
||||
- `heroParticipants`:数组,模板会把每个名字当 key 去 `metadata.avatars[name]` 取头像图
|
||||
- `avatars`:对象,**每个 `heroParticipants` 里的名字都必须有这个 key**(没有就传 `""`,**不要省略整段**),否则模板渲染会抛 `Cannot read properties of undefined (reading '<名字>')` 报 500
|
||||
|
||||
`report` 的以下字段**必须存在**(空就传 `[]`,**不能省略**),否则模板遍历时会抛 `Cannot read properties of undefined (reading 'map')` 报 500:
|
||||
|
||||
- `report.topics`(至少 1 个,完全没话题就改用纯文本总结,不要硬生成空日报)
|
||||
- `report.resources`
|
||||
- `report.importantMessages`
|
||||
- `report.quotes`
|
||||
- `report.qa`
|
||||
- `report.analytics.topicHeat`
|
||||
- `report.analytics.topSpeakers`(至少 1 个)
|
||||
- `report.keywords`
|
||||
|
||||
最小安全示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"overview": "...",
|
||||
"topics": [],
|
||||
"resources": [],
|
||||
"importantMessages": [],
|
||||
"quotes": [],
|
||||
"qa": [],
|
||||
"analytics": { "topicHeat": [], "activeTimeline": "", "topSpeakers": [] },
|
||||
"keywords": []
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
"reportDate": "2026-07-07",
|
||||
"dateRange": "2026-07-07 全天",
|
||||
"heroParticipants": ["张三", "李四"],
|
||||
"avatars": { "张三": "", "李四": "" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`report.importantMessages[].time` 用 `HH:mm` 格式(不要 ISO 时间戳);`report.analytics.topicHeat[].score` 数字 0-10。
|
||||
|
||||
### 4 个数字格子的内容必须紧凑(避免塌陷)
|
||||
|
||||
模板顶部的 4 个统计格(`消息数 / 活跃人数 / 时间跨度 / 主要话题`)宽度均分,内容过长会被截断或换行:
|
||||
|
||||
| 字段 | 推荐格式 | 反例(会撑爆格子) |
|
||||
|------|---------|----------------|
|
||||
| `metadata.messageCount` | 纯数字 `"1234"` | `"约 1.2k 条"` |
|
||||
| `metadata.activeUsers` | 纯数字 `"56"` | `"大约 50 多人"` |
|
||||
| `metadata.timeSpan` | **持续时长紧凑半角** `"1 h"` / `"30 min"` / `"2 d"` | `"1 小时"` / `"7 小时"` / `"1天3小时"` |
|
||||
| `metadata.topicCount` 等 | 数字 / 短中文 | 长句子 |
|
||||
|
||||
`timeSpan` 是**首条到末条消息的持续时长**,不是时间区间。**单位用半角空格分隔**:
|
||||
|
||||
- `< 1 h` → `"30 min"`
|
||||
- `1~24 h` → `"1 h"` / `"7 h"`(整数,向上取整)
|
||||
- `> 24 h` → `"2 d"`(整数,向上取整)
|
||||
|
||||
**首末条消息的具体时间点**:`dateRange` 字段会显示完整日期 + 起止时间(无长度限制),模板里 dateRange 是 hero 区的副标题,跟 stat 格子分开。
|
||||
|
||||
**区间叙事**(如"主要集中在上午 10 点-12 点")放 `report.analytics.activeTimeline`,那是模板里单独一段的描述,不被 stat 格子限制。
|
||||
|
||||
**不传 timeSpan**:服务端会用空字符串渲染(stat 格会空),subagent 应当总是算好时长填进来,或者 renderer 端会自动算(见 renderer 源码)。
|
||||
|
||||
### 头像:服务端自动反推(推荐)
|
||||
|
||||
**v1.4 起无需手动拼 `avatars` 字典**。在 `metadata` 里加 `talker`(群昵称/wxid/md5 都行),服务端会用 `getGroupSnapshot` 拉全量群成员,按 `nickname → avatar` 自动反推填进 `metadata.avatars`。LLM 总结里出现的 `heroParticipants` / `topics[].participants` / `topSpeakers[].name` 等所有名字都会被覆盖。
|
||||
|
||||
**优先级**:客户端传的 `avatars[name]`(非空字符串) > 服务端反推 > 占位 SVG(姓名首字母 + 随机色块)。
|
||||
|
||||
**回退**:不传 `talker` 时按 `metadata.avatars` 字典取;还取不到则生成 SVG 占位(`fallbackAvatar`),**不会变空白方块**(v1.4 修了 data URL 正则,SVG 占位能正常嵌入)。
|
||||
|
||||
**手动覆盖**:仍可传 `avatars` 字典强制使用自定义头像,例如 `{"张三": "data:image/jpeg;base64,..."}`。
|
||||
|
||||
**P2 风险**:群里有两人同名(如"杨伟")时,服务端只取首条;客户端可手动覆盖。
|
||||
|
||||
## 隐私安全原则
|
||||
|
||||
1. **最小化原则**:只返回用户明确请求的内容,不过度展开无关聊天
|
||||
2. **本地处理**:所有数据来自用户本机,API 不缓存、不转发
|
||||
3. **摘要优先**:对于大量聊天记录,先提供摘要而非完整 dump
|
||||
4. **用户确认**:涉及敏感内容时,先展示摘要,让用户决定是否继续深入
|
||||
|
||||
## 典型工作流示例
|
||||
|
||||
**示例 1:今日群聊总结(纯文本)**
|
||||
1. `GET /api/v1/current_time` → 获取今天日期
|
||||
2. `GET /api/v1/chatroom?keyword=技术交流` → 找到目标群 md5
|
||||
3. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉取今天的聊天
|
||||
4. AI 用 LLM 生成总结报告(话题 TOP N、最活跃发言者等)
|
||||
|
||||
**示例 2:搜索特定消息上下文**
|
||||
1. `GET /api/v1/chatlog?talker=摸鱼群&time=2026-07-01~2026-07-03` → 粗查近 3 天
|
||||
2. 在返回的消息中定位关键词出现的时间点 T1, T2, ...
|
||||
3. 对每个 Ti 分别查 `chatlog?talker=摸鱼群&time=Ti-15min~Ti+15min`,分析上下文
|
||||
|
||||
**示例 3:群日报(可视化长图)**
|
||||
1. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉今天聊天
|
||||
2. LLM 按上方 `GroupDailyReport` schema 总结出 `report` + `metadata`
|
||||
3. `POST /api/v1/report` body = 上述 JSON → 拿到 `htmlPath` / `pngPath` / `imageDataUrl`
|
||||
4. 把 `imageDataUrl` 给用户预览,把 `pngPath` 路径告诉用户用 Finder 打开
|
||||
|
||||
## 错误处理
|
||||
|
||||
- `503` → WechatExplorer 未初始化(密钥未配置),提示用户在主窗口完成配置
|
||||
- `404 talker not found` → talker 不存在,先调 `contact` 或 `resolve` 确认 md5/wxid
|
||||
- `400 missing required parameter` → 检查必填参数(talker / md5 / q)
|
||||
- `200` 但 `result.warnings: ['enrich skipped: talker "X" not found']` → `/report` 的 `metadata.talker` 解析失败,头像走 SVG fallback(不阻断生成)
|
||||
- `200` 但 `result.warnings: ['enriched N member avatars from snapshot (M members)']` → enrich 成功(诊断用)
|
||||
- `400 请求体为空 / 需包含 report 和 metadata` → 调用 `/report` 时 body 必须是非空 JSON,且有这两个顶层字段
|
||||
- `500 success=false` → 模板渲染失败,通常因 `report` 字段缺失或 `metadata.groupName/reportDate` 为空,检查后重试
|
||||
|
||||
## 配置 Claude Desktop
|
||||
|
||||
把以下加入 `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechatexplorer": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@wechatexplorer/mcp-bridge"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(待 P3 实现 — MCP bridge 包,在此之前可直接用 `curl` 调用 HTTP API,或通过 mcp-remote 桥接。)
|
||||
|
||||
## 配置 Claude Code / Codex
|
||||
|
||||
在 `~/.claude/settings.json` 或项目级 `.claude/settings.local.json` 中:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechatexplorer": {
|
||||
"url": "http://127.0.0.1:6131"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(视 MCP over HTTP 支持情况调整)
|
||||
@@ -1,5 +1,6 @@
|
||||
appId: com.electron.app
|
||||
productName: wechatexplorer
|
||||
appId: com.wechatexplorer.app
|
||||
productName: WechatExplorer
|
||||
afterPack: scripts/after-pack.cjs
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
@@ -14,24 +15,45 @@ extraMetadata:
|
||||
main: out/main/index.js
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
extraResources:
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- from: docs/skill/wechatexplorer-reader
|
||||
to: skill/wechatexplorer-reader
|
||||
filter:
|
||||
- '**/*'
|
||||
win:
|
||||
executableName: wechatexplorer
|
||||
icon: icon.ico
|
||||
# WCDB's Windows runtime checks the host executable name. The dev runtime is
|
||||
# electron.exe, so keep the packaged executable compatible while preserving
|
||||
# WechatExplorer as the product/shortcut name.
|
||||
executableName: electron
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
icon: icon.icns
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
# The bundled WCDB bridge accepts Electron as its internal host name. The
|
||||
# public app name and bundle identifier remain WechatExplorer-specific.
|
||||
CFBundleName: Electron
|
||||
CFBundleDisplayName: WechatExplorer
|
||||
NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
linux:
|
||||
icon: icon.png
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
@@ -42,5 +64,7 @@ appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: WechatExplorer
|
||||
releaseType: draft
|
||||
|
||||
@@ -3,7 +3,19 @@ import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js'
|
||||
},
|
||||
external: ['koffi']
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "1.0.3",
|
||||
"description": "mac 版本获取微信聊天记录, AI群聊总结助手",
|
||||
"version": "2.1.1",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"chat",
|
||||
"mac微信",
|
||||
"windows微信",
|
||||
"微信聊天记录",
|
||||
"AI群聊总结助手"
|
||||
],
|
||||
"author": "Qingmao",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
@@ -17,35 +22,42 @@
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||
"cp:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:env": "node scripts/ensure-env.cjs",
|
||||
"predev": "node scripts/ensure-env.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac:x64": "electron-vite build && electron-builder --mac --x64",
|
||||
"build:mac:arm64": "electron-vite build && electron-builder --mac --arm64",
|
||||
"release:mac": "electron-vite build && electron-builder --mac --x64 --arm64 --publish always",
|
||||
"build:linux": "electron-vite build && electron-builder --linux"
|
||||
"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:win": "npm run build && electron-builder --config electron-builder.yml --win",
|
||||
"build:mac: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",
|
||||
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"better-sqlite3-multiple-ciphers": "^12.5.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"openai": "^6.10.0"
|
||||
"fzstd": "^0.1.1",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@rollup/rollup-darwin-arm64": "^4.62.2",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
@@ -63,14 +75,5 @@
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 767 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,840 @@
|
||||
<!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;
|
||||
}
|
||||
.mode-tag {
|
||||
display: inline-flex;
|
||||
margin-top: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.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 class="mode-tag">{{REPORT_MODE_LABEL}}</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">
|
||||
数据来源:微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,54 @@
|
||||
const { existsSync, renameSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const COMPATIBILITY_NAME = 'Electron'
|
||||
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
|
||||
|
||||
function setPlistValue(plistPath, key, value) {
|
||||
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== 'darwin') return
|
||||
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
const appPath = path.join(context.appOutDir, `${productName}.app`)
|
||||
const contentsPath = path.join(appPath, 'Contents')
|
||||
const sourceExecutable = path.join(contentsPath, 'MacOS', productName)
|
||||
const targetExecutable = path.join(contentsPath, 'MacOS', COMPATIBILITY_NAME)
|
||||
|
||||
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
|
||||
if (!existsSync(targetExecutable)) {
|
||||
throw new Error(`Missing Electron main executable: ${sourceExecutable}`)
|
||||
}
|
||||
|
||||
const appPlistPath = path.join(contentsPath, 'Info.plist')
|
||||
setPlistValue(appPlistPath, 'CFBundleExecutable', COMPATIBILITY_NAME)
|
||||
setPlistValue(appPlistPath, 'CFBundleName', COMPATIBILITY_NAME)
|
||||
|
||||
const frameworksPath = path.join(contentsPath, 'Frameworks')
|
||||
|
||||
for (const suffix of HELPER_SUFFIXES) {
|
||||
const sourceName = `${productName} Helper${suffix}`
|
||||
const targetName = `${COMPATIBILITY_NAME} Helper${suffix}`
|
||||
const sourceBundle = path.join(frameworksPath, `${sourceName}.app`)
|
||||
const targetBundle = path.join(frameworksPath, `${targetName}.app`)
|
||||
|
||||
if (existsSync(sourceBundle)) renameSync(sourceBundle, targetBundle)
|
||||
if (!existsSync(targetBundle)) {
|
||||
throw new Error(`Missing Electron helper bundle: ${sourceBundle}`)
|
||||
}
|
||||
|
||||
const sourceExecutable = path.join(targetBundle, 'Contents', 'MacOS', sourceName)
|
||||
const targetExecutable = path.join(targetBundle, 'Contents', 'MacOS', targetName)
|
||||
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
|
||||
if (!existsSync(targetExecutable)) {
|
||||
throw new Error(`Missing Electron helper executable: ${sourceExecutable}`)
|
||||
}
|
||||
|
||||
const plistPath = path.join(targetBundle, 'Contents', 'Info.plist')
|
||||
setPlistValue(plistPath, 'CFBundleExecutable', targetName)
|
||||
setPlistValue(plistPath, 'CFBundleName', targetName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const source = path.join(root, '.env.example')
|
||||
const target = path.join(root, '.env')
|
||||
|
||||
if (!fs.existsSync(source) || fs.existsSync(target)) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
fs.copyFileSync(source, target)
|
||||
console.log('[ensure-env] created .env from .env.example')
|
||||
@@ -0,0 +1,48 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const runtimeNames = [
|
||||
'msvcp140.dll',
|
||||
'msvcp140_1.dll',
|
||||
'vcruntime140.dll',
|
||||
'vcruntime140_1.dll'
|
||||
]
|
||||
|
||||
function copyIfDifferent(sourcePath, targetPath) {
|
||||
const source = fs.statSync(sourcePath)
|
||||
const targetExists = fs.existsSync(targetPath)
|
||||
|
||||
if (targetExists) {
|
||||
const target = fs.statSync(targetPath)
|
||||
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32')
|
||||
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist')
|
||||
|
||||
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) return
|
||||
|
||||
let copiedCount = 0
|
||||
for (const name of runtimeNames) {
|
||||
const sourcePath = path.join(sourceDir, name)
|
||||
const targetPath = path.join(targetDir, name)
|
||||
if (!fs.existsSync(sourcePath)) continue
|
||||
if (copyIfDifferent(sourcePath, targetPath)) copiedCount += 1
|
||||
}
|
||||
|
||||
if (copiedCount > 0) {
|
||||
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -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,45 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const ts = require('typescript')
|
||||
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'renderer',
|
||||
'src',
|
||||
'features',
|
||||
'api-center',
|
||||
'utils',
|
||||
'buildSkillInstallInstruction.ts'
|
||||
)
|
||||
const source = fs.readFileSync(filePath, 'utf8')
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const moduleExports = {}
|
||||
new Function('exports', 'require', 'module', output)(moduleExports, require, { exports: moduleExports })
|
||||
|
||||
const { buildSkillInstallInstruction } = moduleExports
|
||||
const local = { type: 'local', directoryPath: 'C:/skill/wechatexplorer-reader', skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', version: 'v1.0' }
|
||||
|
||||
for (const [target, expected] of [
|
||||
['codex', 'Codex 项目或用户 Skill 目录'],
|
||||
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
|
||||
['openclaw', '作为 WechatExplorer Reader Skill 安装'],
|
||||
['generic', '读取并安装']
|
||||
]) {
|
||||
const text = buildSkillInstallInstruction({ target, source: local, apiBaseUrl: { host: '127.0.0.1', port: 6131 } })
|
||||
assert.match(text, new RegExp(expected))
|
||||
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
|
||||
}
|
||||
|
||||
assert.match(
|
||||
buildSkillInstallInstruction({ target: 'codex', source: local, apiBaseUrl: { host: '0.0.0.0', port: 7000 } }),
|
||||
/http:\/\/127\.0\.0\.1:7000\/api\/v1\/health/
|
||||
)
|
||||
assert.match(
|
||||
buildSkillInstallInstruction({ target: 'generic', source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' }, apiBaseUrl: { host: 'localhost', port: 6131 } }),
|
||||
/https:\/\/example\.com\/skill/
|
||||
)
|
||||
|
||||
console.log('skill install instruction tests passed')
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEEPSEEK_API_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
interface StoredKeys {
|
||||
version: 1
|
||||
keys: Record<string, string>
|
||||
}
|
||||
|
||||
export class AIProviderKeyStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'ai-provider-keys.bin')
|
||||
}
|
||||
|
||||
get(providerId: string): { success: boolean; key?: string; error?: string; available: boolean } {
|
||||
const result = this.read()
|
||||
return { ...result, key: result.data?.keys[providerId] }
|
||||
}
|
||||
|
||||
save(providerId: string, key: string): { success: boolean; error?: string } {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
const current = this.read()
|
||||
if (!current.success) return { success: false, error: current.error }
|
||||
const data = current.data || { version: 1 as const, keys: {} }
|
||||
data.keys[providerId] = key
|
||||
return this.write(data)
|
||||
}
|
||||
|
||||
clear(providerId: string): { success: boolean; error?: string } {
|
||||
const current = this.read()
|
||||
if (!current.success) return { success: false, error: current.error }
|
||||
if (!current.data?.keys[providerId]) return { success: true }
|
||||
delete current.data.keys[providerId]
|
||||
try {
|
||||
if (Object.keys(current.data.keys).length === 0) fs.removeSync(this.filePath)
|
||||
else return this.write(current.data)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法清除 AI Provider 密钥' }
|
||||
}
|
||||
}
|
||||
|
||||
private read(): {
|
||||
success: boolean
|
||||
data?: StoredKeys
|
||||
error?: string
|
||||
available: boolean
|
||||
} {
|
||||
const available = safeStorage.isEncryptionAvailable()
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
return { success: true, data: { version: 1, keys: {} }, available }
|
||||
}
|
||||
if (!available) return { success: false, error: '系统安全存储不可用', available }
|
||||
try {
|
||||
const data = JSON.parse(
|
||||
safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
) as StoredKeys
|
||||
if (data.version !== 1 || !data.keys) throw new Error('invalid AI key store')
|
||||
return { success: true, data, available }
|
||||
} catch {
|
||||
return { success: false, error: 'AI Provider 安全存储不可读取', available }
|
||||
}
|
||||
}
|
||||
|
||||
private write(data: StoredKeys): { success: boolean; error?: string } {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), {
|
||||
mode: 0o600
|
||||
})
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: 'AI Provider 密钥保存失败' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
||||
|
||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
||||
|
||||
export const isValidDatabaseKey = (value: string): boolean =>
|
||||
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
|
||||
|
||||
export class DatabaseKeyStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
return {
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<DatabaseKeyStorageResult> {
|
||||
try {
|
||||
const status = await this.getStatus()
|
||||
if (!status.saved) return { success: true, ...status }
|
||||
if (!status.encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', ...status }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return { success: false, error: '已保存的密钥格式无效', ...status }
|
||||
}
|
||||
return { success: true, key, ...status }
|
||||
} catch (error) {
|
||||
const status = await this.getStatus()
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
const key = normalizeDatabaseKey(rawKey)
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '密钥必须是 64 位十六进制字符',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '系统安全存储不可用',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
encryptionAvailable: false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
return { success: true, key, saved: true, encryptionAvailable: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
encryptionAvailable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await fs.remove(this.filePath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,624 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
GroupReportMetadata,
|
||||
ReportHeat,
|
||||
ReportSectionMeta
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
|
||||
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 =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const sanitizeFileName = (value: string): string =>
|
||||
value
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || '未命名群聊'
|
||||
|
||||
const hashName = (name: string): number => {
|
||||
let hash = 0
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||
return hash
|
||||
}
|
||||
|
||||
const fallbackAvatar = (name: string): string => {
|
||||
const hue = hashName(name) % 360
|
||||
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||
}
|
||||
|
||||
const imageMimeType = (contentType: string | null, source: string): string => {
|
||||
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
|
||||
const extension = path.extname(source).toLowerCase()
|
||||
if (extension === '.png') return 'image/png'
|
||||
if (extension === '.webp') return 'image/webp'
|
||||
if (extension === '.gif') return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
||||
if (!source) return fallbackAvatar(name)
|
||||
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 WechatExplorer',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const buffer = await fs.readFile(localPath)
|
||||
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
|
||||
} catch (error) {
|
||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||
return fallbackAvatar(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||
* - 没传 talker → 跳过(向后兼容)
|
||||
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
|
||||
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
|
||||
* - 同名取首条(P2 风险:群里两人同名)
|
||||
*/
|
||||
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
|
||||
if (!metadata.talker) return
|
||||
|
||||
const resolved = resolveMd5(metadata.talker)
|
||||
if (!resolved) {
|
||||
metadata.warnings = metadata.warnings ?? []
|
||||
metadata.warnings.push(`enrich skipped: talker "${metadata.talker}" not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = getGroupSnapshot(resolved.md5)
|
||||
if (!snapshot) {
|
||||
metadata.warnings = metadata.warnings ?? []
|
||||
metadata.warnings.push(`enrich skipped: group snapshot not available for "${metadata.talker}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const index = new Map<string, string>()
|
||||
for (const member of snapshot.members) {
|
||||
if (member.nickname && member.avatar && !index.has(member.nickname)) {
|
||||
index.set(member.nickname, member.avatar)
|
||||
}
|
||||
}
|
||||
|
||||
metadata.avatars = metadata.avatars ?? {}
|
||||
for (const [name, url] of index) {
|
||||
if (metadata.avatars[name]) continue
|
||||
metadata.avatars[name] = url
|
||||
}
|
||||
|
||||
metadata.warnings = metadata.warnings ?? []
|
||||
metadata.warnings.push(
|
||||
`enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)`
|
||||
)
|
||||
}
|
||||
|
||||
const heatClass = (heat: ReportHeat): string => {
|
||||
if (heat === '高') return 'hot'
|
||||
if (heat === '低') return 'blue'
|
||||
return ''
|
||||
}
|
||||
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
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 { report, metadata } = request
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||
report.quotes.forEach((quote) =>
|
||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||
)
|
||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||
|
||||
const avatars = new Map<string, string>()
|
||||
await Promise.all(
|
||||
Array.from(avatarNames).map(async (name) => {
|
||||
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
||||
})
|
||||
)
|
||||
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||
|
||||
const heroNames = metadata.heroParticipants.slice(0, 4)
|
||||
while (heroNames.length < 4) heroNames.push(metadata.groupName)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||
.join('')
|
||||
|
||||
const topicCards = report.topics
|
||||
.map(
|
||||
(topic) => `<div class="card topic-card">
|
||||
<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>
|
||||
<p>${escapeHtml(topic.summary)}</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
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(name) =>
|
||||
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
||||
)
|
||||
.join('')}</div>
|
||||
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||
</div>`
|
||||
)
|
||||
.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 importantMessages = report.importantMessages
|
||||
.map(
|
||||
(message) => `<div class="important-card">
|
||||
<img class="avatar" src="${avatar(message.sender)}" 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 quoteBlocks = report.quotes
|
||||
.map(
|
||||
(quote) =>
|
||||
`<div class="chat-block">${quote.messages
|
||||
.map(
|
||||
(
|
||||
message
|
||||
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" 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 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 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('')
|
||||
|
||||
const rankItems = report.analytics.topSpeakers
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(speaker, index) =>
|
||||
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||
)
|
||||
.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
|
||||
.slice(0, 15)
|
||||
.map(
|
||||
(word, index) =>
|
||||
`<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`
|
||||
)
|
||||
.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 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> = {
|
||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||
// 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,
|
||||
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||
TOPIC_COUNT: String(summaryStats.topicCount),
|
||||
MEDIA_COUNT: String(summaryStats.mediaCount),
|
||||
TOPIC_CARDS: topicCards,
|
||||
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,
|
||||
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
||||
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
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_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,
|
||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||
CONCLUSION_COUNT: String(summaryStats.conclusionCount),
|
||||
TODO_COUNT: String(summaryStats.todoCount),
|
||||
UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
|
||||
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),
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote),
|
||||
// v1 模板独有:从 analytics.topSpeakers 渲染水平条形热度图
|
||||
HEAT_BARS: heatBarsHtml
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||
return html
|
||||
}
|
||||
|
||||
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
})
|
||||
|
||||
try {
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
console.log('[GroupReport] capture loaded html')
|
||||
await reportWindow.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 });
|
||||
}))
|
||||
])`)
|
||||
console.log('[GroupReport] capture assets ready')
|
||||
const metrics = (await reportWindow.webContents.executeJavaScript(`({
|
||||
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
|
||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||
})`)) as { width: number; height: number }
|
||||
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width)))
|
||||
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
||||
reportWindow.setContentSize(width, height)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
|
||||
const png = image.toPNG()
|
||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||
await fs.writeFile(pngPath, png)
|
||||
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
||||
return `data:image/png;base64,${png.toString('base64')}`
|
||||
} finally {
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportGroupReport = async (
|
||||
request: GroupReportExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
try {
|
||||
// === enrich 在 render 之前:从群成员快照反推真头像 ===
|
||||
await enrichAvatarsFromGroup(request.metadata)
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
const htmlStartedAt = new Date()
|
||||
const html = await renderReportHtml(request)
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
htmlPath,
|
||||
pngPath,
|
||||
imageDataUrl,
|
||||
exportTimings: {
|
||||
html: {
|
||||
startedAt: htmlStartedAt.toISOString(),
|
||||
endedAt: htmlEndedAt.toISOString(),
|
||||
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
|
||||
},
|
||||
png: {
|
||||
startedAt: pngStartedAt.toISOString(),
|
||||
endedAt: pngEndedAt.toISOString(),
|
||||
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
|
||||
}
|
||||
},
|
||||
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[GroupReport] export failed:', error)
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
listContacts,
|
||||
listMessages,
|
||||
getGroupSnapshot,
|
||||
listRecentChat,
|
||||
resolveMd5
|
||||
} from './services/chat-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
|
||||
export interface HttpServerHandle {
|
||||
host: string
|
||||
port: number
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
interface RouteContext {
|
||||
req: IncomingMessage
|
||||
res: ServerResponse
|
||||
url: URL
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
const body = JSON.stringify(payload, null, 2)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store'
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function rangeToSec(input: string, endOfUnit = false): number | null {
|
||||
const m = input.match(/^(\d{4})-(\d{2})-(\d{2})(?:\/(\d{2}):(\d{2}))?$/)
|
||||
if (!m) return null
|
||||
const [, y, mo, d, hStr, miStr] = m
|
||||
const hasTime = hStr !== undefined
|
||||
|
||||
let hh: number, mi: number, ss: number, ms: number
|
||||
if (hasTime) {
|
||||
hh = Number(hStr)
|
||||
mi = Number(miStr)
|
||||
ss = endOfUnit ? 59 : 0
|
||||
ms = endOfUnit ? 999 : 0
|
||||
} else if (endOfUnit) {
|
||||
hh = 23
|
||||
mi = 59
|
||||
ss = 59
|
||||
ms = 999
|
||||
} else {
|
||||
hh = 0
|
||||
mi = 0
|
||||
ss = 0
|
||||
ms = 0
|
||||
}
|
||||
|
||||
const date = new Date(Number(y), Number(mo) - 1, Number(d), hh, mi, ss, ms)
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
function parseTimeRange(value: string | null): { startTime?: number; endTime?: number } {
|
||||
if (!value) return {}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return {}
|
||||
|
||||
if (/^\d{10,13}$/.test(trimmed)) {
|
||||
const n = Number(trimmed)
|
||||
if (!Number.isFinite(n)) return {}
|
||||
return { startTime: n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) }
|
||||
}
|
||||
|
||||
if (trimmed.includes('~')) {
|
||||
const [a, b] = trimmed.split('~').map((s) => s.trim())
|
||||
const start = rangeToSec(a, false)
|
||||
const end = rangeToSec(b, true)
|
||||
return {
|
||||
startTime: start ?? undefined,
|
||||
endTime: end ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
const start = rangeToSec(trimmed, false)
|
||||
const end = rangeToSec(trimmed, true)
|
||||
return {
|
||||
startTime: start ?? undefined,
|
||||
endTime: end ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumeric(value: string | null, fallback: number): number {
|
||||
if (!value) return fallback
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
const routes: Record<string, RouteHandler> = {
|
||||
'/api/v1/health': ({ res }) => {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
ready: isReady(),
|
||||
service: 'WechatExplorer Reader',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/current_time': ({ res }) => {
|
||||
const now = new Date()
|
||||
sendJson(res, 200, {
|
||||
time: now.toISOString(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timestamp: Math.floor(now.getTime() / 1000),
|
||||
localDate: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
now.getDate()
|
||||
).padStart(2, '0')}`
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/contact': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const filter = url.searchParams.get('filter') || undefined
|
||||
const type = url.searchParams.get('type') || undefined
|
||||
let contacts = listContacts(filter)
|
||||
if (type === 'user' || type === 'group') {
|
||||
contacts = contacts.filter((c) => c.type === type)
|
||||
}
|
||||
sendJson(res, 200, { count: contacts.length, contacts })
|
||||
},
|
||||
|
||||
'/api/v1/chatroom': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
let groups = listContacts().filter((c) => c.type === 'group')
|
||||
if (keyword) {
|
||||
const lower = keyword.toLowerCase()
|
||||
groups = groups.filter(
|
||||
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
)
|
||||
}
|
||||
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||
},
|
||||
|
||||
'/api/v1/recent_chat': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||
const items = listRecentChat(limit)
|
||||
sendJson(res, 200, { count: items.length, items })
|
||||
},
|
||||
|
||||
'/api/v1/chatlog': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const talker = url.searchParams.get('talker')
|
||||
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||
|
||||
const resolved = resolveMd5(talker)
|
||||
if (!resolved) return sendError(res, 404, `未找到会话: ${talker}`)
|
||||
|
||||
const timeParam = url.searchParams.get('time')
|
||||
const startParam = url.searchParams.get('startTime')
|
||||
const endParam = url.searchParams.get('endTime')
|
||||
|
||||
let startTime: number | undefined
|
||||
let endTime: number | undefined
|
||||
if (timeParam) {
|
||||
const range = parseTimeRange(timeParam)
|
||||
startTime = range.startTime
|
||||
endTime = range.endTime
|
||||
} else {
|
||||
if (startParam) {
|
||||
const r = parseTimeRange(startParam)
|
||||
startTime = r.startTime
|
||||
}
|
||||
if (endParam) {
|
||||
const r = parseTimeRange(endParam)
|
||||
endTime = r.endTime
|
||||
}
|
||||
}
|
||||
|
||||
const messages = listMessages(resolved.md5, startTime, endTime)
|
||||
sendJson(res, 200, {
|
||||
contact: resolved,
|
||||
query: { talker, time: timeParam, startTime, endTime },
|
||||
count: messages.length,
|
||||
messages
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/group_snapshot': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const md5 = url.searchParams.get('md5')
|
||||
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||
const snapshot = getGroupSnapshot(md5)
|
||||
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
|
||||
sendJson(res, 200, snapshot)
|
||||
},
|
||||
|
||||
'/api/v1/resolve': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
const q = url.searchParams.get('q')
|
||||
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||
const contact = resolveMd5(q)
|
||||
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
|
||||
sendJson(res, 200, contact)
|
||||
},
|
||||
|
||||
'/api/v1/report': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
|
||||
}
|
||||
let request: GroupReportExportRequest
|
||||
try {
|
||||
request = JSON.parse(body) as GroupReportExportRequest
|
||||
} catch (error) {
|
||||
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
if (!request?.report || !request?.metadata) {
|
||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
}
|
||||
}
|
||||
|
||||
export function startHttpServer(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT
|
||||
): Promise<HttpServerHandle> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*'
|
||||
})
|
||||
return res.end()
|
||||
}
|
||||
const handler = routes[url.pathname]
|
||||
if (!handler) {
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
let body: string | undefined
|
||||
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
body = await readBody(req)
|
||||
}
|
||||
const ctx: RouteContext = { req, res, url, body }
|
||||
await handler(ctx)
|
||||
} catch (error) {
|
||||
safeError('[HttpServer] 请求处理失败:', error)
|
||||
if (!res.headersSent) {
|
||||
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||
const message =
|
||||
error.code === 'EADDRINUSE'
|
||||
? `端口 ${port} 已被占用,请关闭占用进程或在设置中更换端口`
|
||||
: error.message
|
||||
reject(Object.assign(error, { friendlyMessage: message }))
|
||||
})
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', () => undefined)
|
||||
const actualPort = (server.address() as { port: number } | null)?.port ?? port
|
||||
safeLog(`[HttpServer] Listening on http://${host}:${actualPort}`)
|
||||
resolve({
|
||||
host,
|
||||
port: actualPort,
|
||||
close: () =>
|
||||
new Promise<void>((res) => {
|
||||
server.close(() => res())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface ApiServerState {
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
let singleton: HttpServerHandle | null = null
|
||||
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export const apiServer = {
|
||||
isRunning(): boolean {
|
||||
return singleton !== null
|
||||
},
|
||||
|
||||
getState(): ApiServerState {
|
||||
return { ...singletonState }
|
||||
},
|
||||
|
||||
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
|
||||
if (singleton) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
const maxAttempts = 4
|
||||
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
singleton = await startHttpServer(host, port)
|
||||
singletonState = {
|
||||
running: true,
|
||||
host: singleton.host,
|
||||
port: singleton.port
|
||||
}
|
||||
safeLog(`[ApiServer] started on http://${singleton.host}:${singleton.port}`)
|
||||
return { ...singletonState }
|
||||
} catch (error) {
|
||||
lastError = error as NodeJS.ErrnoException & { friendlyMessage?: string }
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE' || attempt === maxAttempts) break
|
||||
// Brief wait to let the OS release the port (TIME_WAIT / concurrent dev session).
|
||||
await sleep(400 * attempt)
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
lastError?.friendlyMessage ||
|
||||
(lastError instanceof Error ? lastError.message : String(lastError)) ||
|
||||
'API 启动失败'
|
||||
singletonState = {
|
||||
running: false,
|
||||
host,
|
||||
port,
|
||||
error: message
|
||||
}
|
||||
safeError('[ApiServer] start failed:', message)
|
||||
return { ...singletonState }
|
||||
},
|
||||
|
||||
async stop(): Promise<ApiServerState> {
|
||||
if (!singleton) {
|
||||
return this.getState()
|
||||
}
|
||||
try {
|
||||
await singleton.close()
|
||||
} catch (error) {
|
||||
safeWarn('[ApiServer] close failed:', error)
|
||||
}
|
||||
singleton = null
|
||||
singletonState = { ...singletonState, running: false }
|
||||
safeLog('[ApiServer] stopped')
|
||||
return { ...singletonState }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
|
||||
import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
export class ImageDecryptService {
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
|
||||
private xorKey: number = 0
|
||||
private aesKey: string = ''
|
||||
private wcdb4Client: Wcdb4Client | null = null
|
||||
|
||||
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
||||
// 解析 XOR Key (支持 0x40 或 64 格式)
|
||||
const xorHex = xorKey.trim().toLowerCase()
|
||||
if (xorHex.startsWith('0x')) {
|
||||
this.xorKey = parseInt(xorHex, 16)
|
||||
} else {
|
||||
this.xorKey = parseInt(xorHex, 10)
|
||||
}
|
||||
|
||||
// AES Key 直接使用
|
||||
this.aesKey = aesKey.trim()
|
||||
this.wcdb4Client = wcdb4Client || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号目录
|
||||
*/
|
||||
private getAccountDir(): string | null {
|
||||
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
||||
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
||||
return wcdbAccountRoot
|
||||
}
|
||||
|
||||
const homeDir = os.homedir()
|
||||
const accountRoot = join(
|
||||
homeDir,
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||
)
|
||||
|
||||
if (!existsSync(accountRoot)) {
|
||||
console.log('[ImageDecrypt] account root not found:', accountRoot)
|
||||
return null
|
||||
}
|
||||
|
||||
const accounts = readdirSync(accountRoot)
|
||||
.filter((name) => {
|
||||
const fullPath = join(accountRoot, name)
|
||||
try {
|
||||
return statSync(fullPath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.map((name) => ({
|
||||
name,
|
||||
mtime: statSync(join(accountRoot, name)).mtimeMs
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log('[ImageDecrypt] no accounts found')
|
||||
return null
|
||||
}
|
||||
|
||||
// 返回最新的账号目录
|
||||
return join(accountRoot, accounts[0].name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 md5 查找图片文件 (WechatExplorer 风格)
|
||||
*/
|
||||
findImageFile(
|
||||
md5?: string,
|
||||
imageDatName?: string,
|
||||
options?: { allowThumbnail?: boolean }
|
||||
): string | null {
|
||||
const accountDir = this.getAccountDir()
|
||||
if (!accountDir) return null
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
|
||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||
console.log('[ImageDecrypt] findImageFile:', {
|
||||
md5: normalizedMd5,
|
||||
imageDatName: normalizedDatName,
|
||||
accountDir,
|
||||
allowThumbnail
|
||||
})
|
||||
|
||||
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
|
||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||
console.log('[ImageDecrypt] hardlink hit:', selected)
|
||||
return selected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||
const attachDir = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(attachDir)) {
|
||||
console.log('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
||||
}
|
||||
|
||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||
if (searchKeys.length === 0) return null
|
||||
|
||||
for (const key of searchKeys) {
|
||||
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
|
||||
if (directHit) return directHit
|
||||
}
|
||||
|
||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
||||
if (legacyHit) return legacyHit
|
||||
|
||||
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
return null
|
||||
}
|
||||
|
||||
private fastProbabilisticSearch(
|
||||
attachDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
|
||||
const variants = this.buildPreferredDatNames(normalized)
|
||||
|
||||
if (/^[a-f0-9]{32}$/.test(normalized)) {
|
||||
const dir1 = normalized.substring(0, 2)
|
||||
const dir2 = normalized.substring(2, 4)
|
||||
for (const variant of variants) {
|
||||
const candidates = [
|
||||
join(attachDir, dir1, dir2, variant),
|
||||
join(attachDir, dir1, dir2, 'Img', variant),
|
||||
join(attachDir, dir1, dir2, 'Image', variant),
|
||||
join(attachDir, dir1, dir2, 'image', variant)
|
||||
]
|
||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDirs = readdirSync(attachDir).filter(
|
||||
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
for (const sessDir of sessionDirs) {
|
||||
for (const month of months) {
|
||||
for (const sub of ['Img', 'Image', 'image']) {
|
||||
const imgDir = join(attachDir, sessDir, month, sub)
|
||||
if (!existsSync(imgDir)) continue
|
||||
|
||||
const found = this.getLargestExistingPath(
|
||||
variants.map((variant) => join(imgDir, variant)),
|
||||
allowThumbnail
|
||||
)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] found at:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[ImageDecrypt]遍历目录失败:', e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private findImageFileInLegacyDirs(
|
||||
accountDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
|
||||
const roots = [
|
||||
join(accountDir, 'FileStorage', 'Image'),
|
||||
join(accountDir, 'FileStorage', 'Image2'),
|
||||
join(accountDir, 'FileStorage', 'MsgImg')
|
||||
].filter((root) => existsSync(root))
|
||||
|
||||
for (const root of roots) {
|
||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private recursiveFindDat(
|
||||
dir: string,
|
||||
datName: string,
|
||||
depth: number,
|
||||
allowThumbnail = true
|
||||
): string | null {
|
||||
if (depth < 0) return null
|
||||
|
||||
try {
|
||||
const variants = new Set(
|
||||
this.buildPreferredDatNames(datName).filter(
|
||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||
)
|
||||
)
|
||||
const entries = readdirSync(dir)
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||
console.log('[ImageDecrypt] legacy path hit:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
if (!statSync(fullPath).isDirectory()) continue
|
||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
|
||||
if (found) return found
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密图片文件并返回 Buffer
|
||||
*/
|
||||
decryptImage(datPath: string): Buffer | null {
|
||||
if (!existsSync(datPath)) {
|
||||
console.log('[ImageDecrypt] file not found:', datPath)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const version = this.getDatVersion(datPath)
|
||||
console.log(
|
||||
'[ImageDecrypt] dat version:',
|
||||
version,
|
||||
'file:',
|
||||
datPath,
|
||||
'aesKey present:',
|
||||
!!this.aesKey
|
||||
)
|
||||
|
||||
let decrypted: Buffer
|
||||
if (version === 1) {
|
||||
console.log('[ImageDecrypt] using V1 (default AES key)')
|
||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else if (version === 2) {
|
||||
console.log('[ImageDecrypt] using V2 (user AES key)')
|
||||
if (!this.aesKey) {
|
||||
console.log('[ImageDecrypt] no AES key configured')
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else {
|
||||
console.log('[ImageDecrypt] unsupported dat version:', version)
|
||||
return null
|
||||
}
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('[ImageDecrypt] decrypt error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将解密后的图片转换为 base64
|
||||
*/
|
||||
decryptImageToBase64(datPath: string): string | null {
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
const data = readFileSync(datPath)
|
||||
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
|
||||
const mimeType = this.getMimeType(ext)
|
||||
return `data:${mimeType};base64,${data.toString('base64')}`
|
||||
}
|
||||
|
||||
const decrypted = this.decryptImage(datPath)
|
||||
if (!decrypted) return null
|
||||
|
||||
const unwrapped = this.unwrapWxgf(decrypted)
|
||||
const ext = this.detectImageExtension(unwrapped)
|
||||
if (!ext) {
|
||||
console.log('[ImageDecrypt] unknown image format')
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = this.getMimeType(ext)
|
||||
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 首选 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 }
|
||||
}
|
||||
console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 DAT 文件版本
|
||||
*/
|
||||
private getDatVersion(inputPath: string): number {
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 6) {
|
||||
return 0
|
||||
}
|
||||
|
||||
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]))) {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* V4 解密 - AES + XOR
|
||||
*/
|
||||
private decryptDatV4(inputPath: string, aesKey: Buffer): Buffer {
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 0x0f) {
|
||||
throw new Error('文件太小,无法解析')
|
||||
}
|
||||
|
||||
const header = bytes.subarray(0, 0x0f)
|
||||
const data = bytes.subarray(0x0f)
|
||||
|
||||
const aesSize = this.bytesToInt32(header.subarray(6, 10))
|
||||
const xorSize = this.bytesToInt32(header.subarray(10, 14))
|
||||
|
||||
// 对齐 AES 数据到 16 字节边界
|
||||
const remainder = ((aesSize % 16) + 16) % 16
|
||||
const alignedAesSize = aesSize + (16 - remainder)
|
||||
|
||||
if (alignedAesSize > data.length) {
|
||||
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
|
||||
}
|
||||
|
||||
// 解密 AES 数据
|
||||
const aesData = data.subarray(0, alignedAesSize)
|
||||
let unpadded: Buffer = Buffer.alloc(0)
|
||||
if (aesData.length > 0) {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
|
||||
decipher.setAutoPadding(false)
|
||||
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
|
||||
unpadded = this.strictRemovePadding(decrypted)
|
||||
}
|
||||
|
||||
// 解密 XOR 数据
|
||||
const remaining = data.subarray(alignedAesSize)
|
||||
if (xorSize < 0 || xorSize > remaining.length) {
|
||||
throw new Error('文件格式异常:XOR 数据长度不合法')
|
||||
}
|
||||
|
||||
let rawData: Buffer
|
||||
let xoredData: Buffer
|
||||
if (xorSize > 0) {
|
||||
const rawLength = remaining.length - xorSize
|
||||
if (rawLength < 0) {
|
||||
throw new Error('文件格式异常:原始数据长度小于XOR长度')
|
||||
}
|
||||
rawData = remaining.subarray(0, rawLength)
|
||||
const xorData = remaining.subarray(rawLength)
|
||||
xoredData = Buffer.alloc(xorData.length)
|
||||
for (let i = 0; i < xorData.length; i += 1) {
|
||||
xoredData[i] = xorData[i] ^ this.xorKey
|
||||
}
|
||||
} else {
|
||||
rawData = remaining
|
||||
xoredData = Buffer.alloc(0)
|
||||
}
|
||||
|
||||
return Buffer.concat([unpadded, rawData, xoredData])
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测图片扩展名
|
||||
*/
|
||||
private detectImageExtension(buffer: Buffer): string | null {
|
||||
if (buffer.length < 4) return null
|
||||
|
||||
const SIGNATURES: Record<string, Buffer> = {
|
||||
'.jpg': Buffer.from([0xff, 0xd8, 0xff]),
|
||||
'.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
'.gif': Buffer.from([0x47, 0x49, 0x46, 0x38]),
|
||||
'.bmp': Buffer.from([0x42, 0x4d]),
|
||||
'.webp': Buffer.from([0x52, 0x49, 0x46, 0x46])
|
||||
}
|
||||
|
||||
for (const [ext, sig] of Object.entries(SIGNATURES)) {
|
||||
if (this.compareBytes(buffer.subarray(0, sig.length), sig)) {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private getMimeType(ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
return mimeTypes[ext] || 'image/jpeg'
|
||||
}
|
||||
|
||||
private normalizeDatBase(value: string): string {
|
||||
const lower = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!lower) return ''
|
||||
const file = lower.split('/').pop()?.split('\\').pop() || lower
|
||||
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
|
||||
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
private buildPreferredDatNames(baseName: string): string[] {
|
||||
const base = this.normalizeDatBase(baseName)
|
||||
if (!base) return []
|
||||
return [
|
||||
`${base}.dat`,
|
||||
`${base}_hd.dat`,
|
||||
`${base}_h.dat`,
|
||||
`${base}_b.dat`,
|
||||
`${base}_w.dat`,
|
||||
`${base}_c.dat`,
|
||||
`${base}_t.dat`,
|
||||
`${base}.thumb.dat`,
|
||||
`${base}_thumb.dat`
|
||||
]
|
||||
}
|
||||
|
||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
||||
const actualDir = dirname(inputPath)
|
||||
const base = this.normalizeDatBase(basename(inputPath))
|
||||
const variants = this.buildPreferredDatNames(base)
|
||||
const ordered = allowThumbnail
|
||||
? variants
|
||||
: variants.filter((name) => !this.isThumbnailName(name))
|
||||
const largest = this.getLargestExistingPath(
|
||||
ordered.map((variant) => join(actualDir, variant)),
|
||||
allowThumbnail
|
||||
)
|
||||
if (largest) return largest
|
||||
return inputPath
|
||||
}
|
||||
|
||||
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
|
||||
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
||||
candidates
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
.map((candidate) => {
|
||||
try {
|
||||
return { candidate, size: statSync(candidate).size }
|
||||
} catch {
|
||||
return { candidate, size: 0 }
|
||||
}
|
||||
})
|
||||
.sort((left, right) => right.size - left.size)
|
||||
|
||||
const nonThumb = toSized(
|
||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
if (nonThumb[0]) return nonThumb[0].candidate
|
||||
if (!allowThumbnail) return null
|
||||
|
||||
const existing = toSized(paths)
|
||||
return existing[0]?.candidate || null
|
||||
}
|
||||
|
||||
private isThumbnailName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||
}
|
||||
|
||||
isThumbnailFile(filePath: string): boolean {
|
||||
return this.isThumbnailName(basename(filePath))
|
||||
}
|
||||
|
||||
private unwrapWxgf(buffer: Buffer): Buffer {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 ||
|
||||
buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 ||
|
||||
buffer[3] !== 0x66
|
||||
) {
|
||||
return buffer
|
||||
}
|
||||
|
||||
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i += 1) {
|
||||
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
|
||||
return buffer.subarray(i)
|
||||
}
|
||||
if (
|
||||
buffer[i] === 0x89 &&
|
||||
buffer[i + 1] === 0x50 &&
|
||||
buffer[i + 2] === 0x4e &&
|
||||
buffer[i + 3] === 0x47
|
||||
) {
|
||||
return buffer.subarray(i)
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
private uniq(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
private bytesToInt32(bytes: Buffer): number {
|
||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||
}
|
||||
|
||||
private compareBytes(a: Buffer, b: Buffer): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private strictRemovePadding(buffer: Buffer): Buffer {
|
||||
if (buffer.length === 0) return buffer
|
||||
const lastByte = buffer[buffer.length - 1]
|
||||
if (lastByte <= 16 && lastByte > 0) {
|
||||
const paddingLength = lastByte
|
||||
let valid = true
|
||||
for (let i = buffer.length - paddingLength; i < buffer.length; i++) {
|
||||
if (buffer[i] !== lastByte) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
return buffer.subarray(0, buffer.length - paddingLength)
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
export interface StoredImageKeyEntry {
|
||||
xorKey: string
|
||||
aesKey: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface StoredImageKeyFile {
|
||||
version: 1
|
||||
accounts: Record<string, StoredImageKeyEntry>
|
||||
}
|
||||
|
||||
interface StoreReadResult {
|
||||
success: boolean
|
||||
data?: StoredImageKeyFile
|
||||
error?: string
|
||||
encryptionAvailable: boolean
|
||||
}
|
||||
|
||||
export class ImageKeyStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-image-keys.bin')
|
||||
}
|
||||
|
||||
get(accountId: string): StoreReadResult & { entry?: StoredImageKeyEntry } {
|
||||
const result = this.read()
|
||||
return { ...result, entry: result.data?.accounts[accountId] }
|
||||
}
|
||||
|
||||
save(
|
||||
accountId: string,
|
||||
entry: Omit<StoredImageKeyEntry, 'updatedAt'>
|
||||
): { success: boolean; entry?: StoredImageKeyEntry; error?: string } {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
const current = this.read()
|
||||
if (!current.success && fs.existsSync(this.filePath)) {
|
||||
return { success: false, error: '无法读取现有图片密钥配置' }
|
||||
}
|
||||
const nextEntry = { ...entry, updatedAt: Date.now() }
|
||||
const data: StoredImageKeyFile = current.data || { version: 1, accounts: {} }
|
||||
data.accounts[accountId] = nextEntry
|
||||
const saved = this.write(data)
|
||||
return saved.success ? { success: true, entry: nextEntry } : saved
|
||||
}
|
||||
|
||||
clear(accountId: string): { success: boolean; error?: string } {
|
||||
const current = this.read()
|
||||
if (!current.success) return { success: false, error: current.error }
|
||||
if (!current.data?.accounts[accountId]) return { success: true }
|
||||
delete current.data.accounts[accountId]
|
||||
try {
|
||||
if (Object.keys(current.data.accounts).length === 0) fs.removeSync(this.filePath)
|
||||
else return this.write(current.data)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法清除图片密钥配置' }
|
||||
}
|
||||
}
|
||||
|
||||
private read(): StoreReadResult {
|
||||
const encryptionAvailable = safeStorage.isEncryptionAvailable()
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { version: 1, accounts: {} },
|
||||
encryptionAvailable
|
||||
}
|
||||
}
|
||||
if (!encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', encryptionAvailable }
|
||||
}
|
||||
try {
|
||||
const decrypted = safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
const data = JSON.parse(decrypted) as StoredImageKeyFile
|
||||
if (data.version !== 1 || !data.accounts) throw new Error('invalid image key store')
|
||||
return { success: true, data, encryptionAvailable }
|
||||
} catch {
|
||||
return { success: false, error: '图片密钥安全存储不可读取', encryptionAvailable }
|
||||
}
|
||||
}
|
||||
|
||||
private write(data: StoredImageKeyFile): { success: boolean; error?: string } {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), {
|
||||
mode: 0o600
|
||||
})
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '图片密钥保存失败' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,117 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard } from 'electron'
|
||||
import './preload-env'
|
||||
import {
|
||||
app,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
clipboard,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog
|
||||
} from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { WechatDb, Contact, WechatMessage } from './wechat-db'
|
||||
import { WechatDb } from './wechat-db'
|
||||
import { bootstrapWcdbNative } from './wcdb4-client'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { parseMessageContent } from './message-parser'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import {
|
||||
deleteGeneratedReport,
|
||||
listGeneratedReports,
|
||||
saveGeneratedReport
|
||||
} from './report-history-service'
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AIProviderConfig,
|
||||
AIVisionTestRequest,
|
||||
LegacyAIConfig
|
||||
} from '../shared/ai-provider'
|
||||
import { DatabaseKeyStore } from './database-key-store'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-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 { KeyService as KeyServiceWin } from './key-service-win'
|
||||
import * as chat from './services/chat-service'
|
||||
import { apiServer } from './http-server'
|
||||
import { skillResourceService } from './services/skill-resource-service'
|
||||
import { testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWindowsWechatRunning } from './services/wechat-process-status'
|
||||
import {
|
||||
inspectImageDecryptionStatus,
|
||||
testImageDecryption
|
||||
} from './services/image-decryption-status-service'
|
||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||
import {
|
||||
getBootstrapCache,
|
||||
getCachedMessages,
|
||||
mergeBootstrapAvatars,
|
||||
mergeCachedContactAvatars,
|
||||
saveBootstrapContacts,
|
||||
saveBootstrapSelf,
|
||||
saveCachedMessages
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
|
||||
let wechatDb: WechatDb | null = null
|
||||
// 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
|
||||
// handler. Wrap console.* before any other module logs anything.
|
||||
installSafeConsole()
|
||||
|
||||
const MSG_TYPE_DICT: Record<number, string> = {
|
||||
1: '普通文本',
|
||||
3: '图片',
|
||||
34: '语音',
|
||||
43: '视频',
|
||||
47: '表情包',
|
||||
48: '位置',
|
||||
49: '分享消息',
|
||||
10000: '系统消息'
|
||||
let voiceService: VoiceService | null = null
|
||||
let imageDecryptService: ImageDecryptService | null = null
|
||||
let stickerService: StickerService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const aiProviderService = new AIProviderService()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
let tray: Tray | null = null
|
||||
|
||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
||||
|
||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||
null
|
||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
const TRAY_MODE =
|
||||
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
||||
|
||||
function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
||||
const config = imageKeyConfigService.getConfig()
|
||||
return {
|
||||
xorKey: config.xorKey || '0x40',
|
||||
aesKey: config.aesKey || ''
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// 创建浏览器窗口
|
||||
// 鍒涘缓娴忚鍣ㄧ獥鍙?
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 670,
|
||||
width: 1400,
|
||||
height: 800,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
icon: appIconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
@@ -40,8 +127,8 @@ function createWindow(): void {
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 基于 electron-vite cli 的渲染器 HMR
|
||||
// 加载开发环境的远程 URL 或生产环境的本地 html 文件
|
||||
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
|
||||
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
@@ -49,15 +136,29 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(() => {
|
||||
// 为窗口设置应用程序用户模型 ID
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
|
||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
||||
app.whenReady().then(async () => {
|
||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||
|
||||
// 在开发环境中默认按 F12 打开或关闭 DevTools
|
||||
// 在生产环境中忽略 CommandOrControl + R
|
||||
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
||||
// reuses the already-initialized library and skips wcdb_init.
|
||||
try {
|
||||
bootstrapWcdbNative()
|
||||
console.log('[WCDB4] bootstrap complete at whenReady top')
|
||||
} catch (bootstrapError) {
|
||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
||||
}
|
||||
|
||||
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
|
||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||
|
||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
||||
|
||||
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
|
||||
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
|
||||
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
@@ -65,154 +166,238 @@ app.whenReady().then(() => {
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
ipcMain.handle('db:init', (_, key: string) => {
|
||||
try {
|
||||
wechatDb = new WechatDb(key)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to init DB:', error)
|
||||
return false
|
||||
ipcMain.handle('db:init', async (_, key: string) => {
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
try {
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const settings = loadSettings()
|
||||
if (
|
||||
chat.isReady() &&
|
||||
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
|
||||
(!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
|
||||
) {
|
||||
console.log('[WCDB4] db:init reuse current connection')
|
||||
return { success: true, monitoring: true }
|
||||
}
|
||||
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
||||
saveSettings({ ...settings, dbRoot: resolvedRoot })
|
||||
}
|
||||
chat.setChatDb(nextWechatDb)
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
}
|
||||
})
|
||||
imageDecryptService = null
|
||||
return { success: true, monitoring }
|
||||
} catch (error) {
|
||||
console.error('Failed to init DB:', error)
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
dbInitInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return dbInitInFlight
|
||||
})
|
||||
|
||||
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
|
||||
|
||||
ipcMain.handle('key:getEnvironment', async () => {
|
||||
const storage = await databaseKeyStore.getStatus()
|
||||
const self = chat.getSelfAccountInfo()
|
||||
return {
|
||||
platform: process.platform,
|
||||
autoDetectSupported: process.platform === 'win32',
|
||||
wechatRunning: await isWindowsWechatRunning(),
|
||||
accountIdentified: Boolean(self?.wxid),
|
||||
dbConnected: chat.isReady(),
|
||||
encryptionAvailable: storage.encryptionAvailable
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getContacts', (_, filter?: string) => {
|
||||
if (!wechatDb) return []
|
||||
ipcMain.handle('key:readClipboardDbKey', () => {
|
||||
try {
|
||||
return { success: true, value: clipboard.readText().trim() }
|
||||
} catch {
|
||||
return { success: false, error: '无法读取剪贴板' }
|
||||
}
|
||||
})
|
||||
|
||||
const contacts: Contact[] = []
|
||||
const groupContacts = wechatDb.getAllGroupContacts()
|
||||
const userList = wechatDb.getUserList(filter)
|
||||
const existingMd5s = new Set<string>()
|
||||
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
|
||||
const clipboardKey = clipboard.readText().trim()
|
||||
return databaseKeyStore.save(clipboardKey)
|
||||
})
|
||||
|
||||
// 1. 处理普通联系人
|
||||
for (const user of userList) {
|
||||
const md5 = wechatDb.md5(user.m_nsUsrName)
|
||||
existingMd5s.add(md5)
|
||||
contacts.push({
|
||||
m_nsUsrName: user.m_nsUsrName,
|
||||
m_nsNickName: user.nickname || '未知用户',
|
||||
md5: md5,
|
||||
type: 'user'
|
||||
})
|
||||
ipcMain.handle('key:saveDbKey', async (_, key: string) =>
|
||||
databaseKeyStore.save(String(key || ''))
|
||||
)
|
||||
|
||||
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
|
||||
|
||||
ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => {
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||
}
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
|
||||
: await keyServiceMac.autoGetDbKey(onStatus)
|
||||
if (!result.success || !result.key) return result
|
||||
|
||||
if (options?.save === false) return result
|
||||
|
||||
const saved = await databaseKeyStore.save(result.key)
|
||||
return {
|
||||
...result,
|
||||
saved: saved.success,
|
||||
warning: saved.success ? undefined : saved.error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||
const settings = loadSettings()
|
||||
const self = chat.getSelfAccountInfo()
|
||||
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
|
||||
const wxid = self?.wxid
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
|
||||
}
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await keyServiceWin.autoGetImageKeyByMemoryScan(accountRoot, onStatus)
|
||||
: await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid)
|
||||
|
||||
if (!result.success || !result.aesKey) return result
|
||||
if (process.platform === 'win32') {
|
||||
onStatus('发现候选密钥,图片模板验证通过')
|
||||
}
|
||||
|
||||
// 2. 处理聊天表
|
||||
const chatTables = wechatDb.getAllChatTables()
|
||||
for (const table of chatTables) {
|
||||
if (!table.name.startsWith('Chat_')) continue
|
||||
const md5 = table.name.substring(5)
|
||||
const imageXorKey = `0x${Number(result.xorKey ?? 0x40)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(2, '0')}`
|
||||
const verified = result.verified ?? process.platform === 'win32'
|
||||
if (options?.save === false) {
|
||||
return { ...result, verified, imageXorKey, imageAesKey: result.aesKey }
|
||||
}
|
||||
const saved = imageKeyConfigService.save({
|
||||
resourceRoot: accountRoot,
|
||||
xorKey: imageXorKey,
|
||||
aesKey: result.aesKey
|
||||
})
|
||||
if (saved.success) imageDecryptService = null
|
||||
return {
|
||||
...result,
|
||||
success: saved.success,
|
||||
error: saved.success ? undefined : saved.error,
|
||||
verified,
|
||||
imageXorKey,
|
||||
imageAesKey: result.aesKey,
|
||||
settings: imageKeyConfigService.getLegacySettingsView()
|
||||
}
|
||||
})
|
||||
|
||||
if (!existingMd5s.has(md5)) {
|
||||
if (groupContacts[md5]) {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Group_${md5}`,
|
||||
m_nsNickName: groupContacts[md5],
|
||||
md5: md5,
|
||||
type: 'group'
|
||||
})
|
||||
} else {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Unknown_${md5}`,
|
||||
m_nsNickName: `Chat_${md5}`,
|
||||
md5: md5,
|
||||
type: 'user'
|
||||
})
|
||||
}
|
||||
}
|
||||
ipcMain.handle('image:getConfig', () => imageKeyConfigService.getConfig())
|
||||
|
||||
ipcMain.handle('image:getStatus', async () =>
|
||||
inspectImageDecryptionStatus(imageKeyConfigService.getConfig())
|
||||
)
|
||||
|
||||
ipcMain.handle('image:saveConfig', (_, request: SaveImageKeyRequest) => {
|
||||
const result = imageKeyConfigService.save(request)
|
||||
if (result.success) imageDecryptService = null
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('image:testConfig', (_, request: TestImageDecryptionRequest) =>
|
||||
testImageDecryption(request)
|
||||
)
|
||||
|
||||
ipcMain.handle('image:clearConfig', () => {
|
||||
const result = imageKeyConfigService.clear()
|
||||
if (result.success) imageDecryptService = null
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getBootstrapCache', () => {
|
||||
if (!chat.isReady()) return null
|
||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getCachedMessages',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||
if (!chat.isReady()) return []
|
||||
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getContacts', (_, filter?: string) => {
|
||||
const accountRoot = chat.getCurrentAccountRoot()
|
||||
const contacts = accountRoot
|
||||
? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter))
|
||||
: chat.listContacts(filter)
|
||||
if (!filter && chat.isReady() && accountRoot) {
|
||||
saveBootstrapContacts(accountRoot, contacts)
|
||||
}
|
||||
return contacts
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||
if (!wechatDb) return []
|
||||
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
|
||||
const groupMembers = wechatDb.getAllGroupMembers()
|
||||
|
||||
return rawMessages.map((msg: WechatMessage) => {
|
||||
const msgType = parseInt(msg.messageType)
|
||||
const createTime = parseInt(msg.msgCreateTime)
|
||||
const date = new Date(createTime * 1000)
|
||||
|
||||
let content = msg.msgContent
|
||||
let img = ''
|
||||
let name = ''
|
||||
// 检查内容是否以 wxid 开头并包含冒号
|
||||
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
|
||||
if (content && typeof content === 'string') {
|
||||
const colonIndex = content.indexOf(':')
|
||||
if (colonIndex > 0) {
|
||||
const potentialWxid = content.substring(0, colonIndex)
|
||||
if (potentialWxid.startsWith('wxid_')) {
|
||||
// 尝试获取头像
|
||||
if (wechatDb) {
|
||||
const member = wechatDb.getGroupMember(potentialWxid)
|
||||
if (member) {
|
||||
img = member.m_nsHeadImgUrl
|
||||
}
|
||||
}
|
||||
|
||||
if (groupMembers[potentialWxid]) {
|
||||
const nickname = groupMembers[potentialWxid]
|
||||
name = nickname
|
||||
content = content.substring(colonIndex + 1) // +1 to skip the colon
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.mesLocalID || Math.random().toString(),
|
||||
from: msg.mesDes === 1 ? 'user' : 'assistant', // 1 通常是接收到的,0 是发送的?需要验证。Swift 说:[1: "user", 0: "assistant"]
|
||||
type: MSG_TYPE_DICT[msgType] || msg.messageType,
|
||||
datetime: date.toLocaleString('zh-CN', { hour12: false }),
|
||||
content: content,
|
||||
img: img,
|
||||
name: name
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('db:search', (_, keyword: string) => {
|
||||
if (!wechatDb) return null
|
||||
return wechatDb.searchAllMessages(keyword)
|
||||
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => {
|
||||
const avatars = chat.getContactAvatars(usernames)
|
||||
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
||||
return avatars
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'ai:chat',
|
||||
async (
|
||||
_,
|
||||
messages: { role: string; content: string }[],
|
||||
options?: { apiKey?: string; model?: string }
|
||||
) => {
|
||||
// @ts-ignore: vite env
|
||||
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
|
||||
const model = options?.model || 'deepseek-chat'
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: '未配置 DeepSeek API Key' }
|
||||
}
|
||||
|
||||
// 动态导入以避免如果未安装或初始类型缺失的问题
|
||||
const { OpenAI } = await import('openai')
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: apiKey
|
||||
})
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
messages: messages as any,
|
||||
model: model
|
||||
})
|
||||
return { success: true, data: completion.choices[0].message.content }
|
||||
} catch (error: unknown) {
|
||||
console.error('DeepSeek API Error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return { success: false, error: errorMessage }
|
||||
'db:getMessages',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
||||
const messages = chat.listMessages(userMd5, startTime, endTime, options)
|
||||
if (chat.isReady()) {
|
||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
||||
|
||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||
|
||||
ipcMain.handle(
|
||||
'ai:chat',
|
||||
async (_, messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
|
||||
aiProviderService.chat(messages, options)
|
||||
)
|
||||
|
||||
ipcMain.handle('ai:listProviders', () => aiProviderService.list())
|
||||
ipcMain.handle('ai:getRuntimeConfig', () => aiProviderService.getRuntimeConfig())
|
||||
ipcMain.handle('ai:saveProvider', (_, provider: AIProviderConfig) =>
|
||||
aiProviderService.save(provider)
|
||||
)
|
||||
ipcMain.handle('ai:deleteProvider', (_, providerId: string) =>
|
||||
aiProviderService.delete(providerId)
|
||||
)
|
||||
ipcMain.handle('ai:setDefaultProvider', (_, providerId: string) =>
|
||||
aiProviderService.setDefault(providerId)
|
||||
)
|
||||
ipcMain.handle('ai:testProvider', (_, providerId: string) => aiProviderService.test(providerId))
|
||||
ipcMain.handle('ai:testVision', (_, request: AIVisionTestRequest) =>
|
||||
aiProviderService.testVision(request)
|
||||
)
|
||||
ipcMain.handle('ai:migrateLegacy', (_, config: LegacyAIConfig) =>
|
||||
aiProviderService.migrateLegacy(config)
|
||||
)
|
||||
|
||||
ipcMain.handle('copy-image', async (_, base64String) => {
|
||||
try {
|
||||
const image = nativeImage.createFromDataURL(base64String)
|
||||
@@ -223,20 +408,384 @@ app.whenReady().then(() => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle('report:listGenerated', async () => {
|
||||
return listGeneratedReports()
|
||||
})
|
||||
|
||||
ipcMain.handle('report:saveGenerated', async (_, request: SaveGeneratedReportRequest) => {
|
||||
return saveGeneratedReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
|
||||
return deleteGeneratedReport(reportId)
|
||||
})
|
||||
|
||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||
try {
|
||||
shell.showItemInFolder(filePath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getVoiceData',
|
||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||
if (!voiceService) {
|
||||
return { success: false, error: 'VoiceService 未初始化' }
|
||||
}
|
||||
return voiceService.resolveVoice(sessionId, localId, createTime, svrId)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:parseMessage', async (_, content: string, messageType: number) => {
|
||||
return parseMessageContent(content, messageType)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getImage',
|
||||
async (
|
||||
_,
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
_sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
) => {
|
||||
void _sessionId
|
||||
if (!imageDecryptService) {
|
||||
const { xorKey, aesKey } = getConfiguredImageKeys()
|
||||
if (!aesKey) {
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
}
|
||||
imageDecryptService = new ImageDecryptService(
|
||||
xorKey,
|
||||
aesKey,
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
)
|
||||
}
|
||||
|
||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||
const force = options?.force === true
|
||||
let filePath = force
|
||||
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||
: null
|
||||
if (!filePath) {
|
||||
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||
allowThumbnail: true
|
||||
})
|
||||
}
|
||||
if (!filePath) {
|
||||
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
||||
}
|
||||
|
||||
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
|
||||
if (!decrypted) {
|
||||
return { success: false, error: '图片解密失败' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: decrypted.data,
|
||||
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||
filePath: decrypted.filePath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 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 模型不存在' }
|
||||
}
|
||||
if (!model.capabilities.vision) {
|
||||
return { success: false, error: '当前模型不支持图片理解' }
|
||||
}
|
||||
// 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) }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
|
||||
if (!stickerService) {
|
||||
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
|
||||
}
|
||||
return stickerService.resolveSticker(cdnUrl, md5)
|
||||
})
|
||||
|
||||
// -------- Settings & API service --------
|
||||
|
||||
ipcMain.handle('settings:get', () => ({
|
||||
settings: imageKeyConfigService.getLegacySettingsView(),
|
||||
settingsPath: getSettingsPath()
|
||||
}))
|
||||
|
||||
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
|
||||
const before = loadSettings()
|
||||
const current = imageKeyConfigService.getConfig()
|
||||
const resourceRoot = patch.imageKeyRoot ?? before.imageKeyRoot
|
||||
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
|
||||
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
|
||||
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
|
||||
if (includesImageKey) {
|
||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
|
||||
else imageKeyConfigService.clear()
|
||||
imageDecryptService = null
|
||||
} else {
|
||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||
}
|
||||
return {
|
||||
settings: imageKeyConfigService.getLegacySettingsView(),
|
||||
settingsPath: getSettingsPath()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:getSelf', () => {
|
||||
const info = chat.getSelfAccountInfo()
|
||||
if (!info) return { ready: false }
|
||||
if (chat.isReady()) saveBootstrapSelf(chat.getCurrentAccountRoot(), info)
|
||||
return { ready: true, info }
|
||||
})
|
||||
|
||||
ipcMain.handle('db:testConnection', (_, key: string, accountRoot?: string) => {
|
||||
return chat.testConnection(key, accountRoot)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
||||
const ok = chat.reopenWithRoot(accountRoot)
|
||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||
const info = chat.getSelfAccountInfo()
|
||||
return { success: true, info }
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:selectDbRoot', async (event) => {
|
||||
const result = await dialog.showOpenDialog(BrowserWindow.fromWebContents(event.sender)!, {
|
||||
title: '选择微信数据库目录',
|
||||
defaultPath: loadSettings().dbRoot || undefined,
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:openAccountRoot', async () => {
|
||||
const accountRoot = chat.getCurrentAccountRoot()
|
||||
if (!accountRoot) return { success: false, error: '当前没有可打开的账号目录' }
|
||||
const error = await shell.openPath(accountRoot)
|
||||
return error ? { success: false, error } : { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('db:disconnect', () => {
|
||||
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
|
||||
chat.setChatDb(null)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('api:getStatus', () => apiServer.getState())
|
||||
|
||||
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
|
||||
const settings = loadSettings()
|
||||
const target = {
|
||||
host: host || settings.apiHost,
|
||||
port: port || settings.apiPort
|
||||
}
|
||||
if (host || port) saveSettings({ ...settings, ...target })
|
||||
return apiServer.start(target.host, target.port)
|
||||
})
|
||||
|
||||
ipcMain.handle('api:stop', async () => apiServer.stop())
|
||||
|
||||
ipcMain.handle('api:toggle', async (_, enabled: boolean) => {
|
||||
const settings = saveSettings({ ...loadSettings(), apiEnabled: enabled })
|
||||
if (enabled) {
|
||||
return apiServer.start(settings.apiHost, settings.apiPort)
|
||||
}
|
||||
return apiServer.stop()
|
||||
})
|
||||
|
||||
ipcMain.handle('api:skillStatus', () => skillResourceService.getStatus())
|
||||
ipcMain.handle('api:readSkill', () => skillResourceService.read())
|
||||
ipcMain.handle('api:revealSkill', () => skillResourceService.reveal())
|
||||
ipcMain.handle('api:openSkillGithub', () => skillResourceService.openGithub())
|
||||
ipcMain.handle('api:testLocalRequest', (_, request) => testLocalApiRequest(request))
|
||||
ipcMain.handle('api:copyText', (_, text: unknown) => {
|
||||
if (typeof text !== 'string' || text.length > 1024 * 1024) {
|
||||
return { success: false, error: '复制内容无效或过大' }
|
||||
}
|
||||
try {
|
||||
clipboard.writeText(text)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
createWindow()
|
||||
|
||||
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
|
||||
const settings = loadSettings()
|
||||
if (settings.apiEnabled) {
|
||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||
}
|
||||
|
||||
if (TRAY_MODE) {
|
||||
app.dock?.hide()
|
||||
setupTray()
|
||||
}
|
||||
|
||||
app.on('activate', function () {
|
||||
// 在 macOS 上,当点击 dock 图标且没有其他窗口打开时,
|
||||
// 通常会在应用程序中重新创建一个窗口。
|
||||
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
|
||||
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// 当所有窗口关闭时退出,除了 macOS。在那里,
|
||||
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||
// 显式使用 Cmd + Q 退出。
|
||||
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
|
||||
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
|
||||
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
|
||||
app.on('window-all-closed', () => {
|
||||
if (TRAY_MODE) return
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
chat.setChatDb(null)
|
||||
await apiServer.stop().catch(() => undefined)
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
}
|
||||
})
|
||||
|
||||
function showMainWindow(): void {
|
||||
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
|
||||
const wins = BrowserWindow.getAllWindows()
|
||||
if (wins.length === 0) {
|
||||
createWindow()
|
||||
return
|
||||
}
|
||||
const win = wins[0]
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
|
||||
function buildTrayMenu(): Menu {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: '打开主窗口',
|
||||
click: () => showMainWindow()
|
||||
},
|
||||
{
|
||||
label: 'API 状态',
|
||||
click: () => showMainWindow()
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出 WechatExplorer',
|
||||
click: () => {
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function setupTray(): void {
|
||||
if (tray) return
|
||||
try {
|
||||
const image = nativeImage.createFromPath(appIconPath)
|
||||
const traySize = process.platform === 'darwin' ? 20 : 24
|
||||
const trayImage = image.isEmpty()
|
||||
? nativeImage.createEmpty()
|
||||
: image.resize({ width: traySize, height: traySize, quality: 'best' })
|
||||
tray = new Tray(trayImage)
|
||||
tray.setToolTip('WechatExplorer')
|
||||
tray.setContextMenu(buildTrayMenu())
|
||||
tray.on('click', () => showMainWindow())
|
||||
} catch (error) {
|
||||
console.warn('[Tray] Failed to create tray:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isValidDatabaseKey } from './database-key-store'
|
||||
import crypto from 'crypto'
|
||||
import { findResource, getResourceCandidates } from './resource-paths'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export interface DatabaseKeyResult {
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface ImageKeyResult {
|
||||
success: boolean
|
||||
xorKey?: number
|
||||
aesKey?: string
|
||||
verified?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class KeyServiceMac {
|
||||
private getHelperPath(): string {
|
||||
const helperPath = findResource('xkey_helper')
|
||||
if (!helperPath) {
|
||||
throw new Error(
|
||||
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})`
|
||||
)
|
||||
}
|
||||
return helperPath
|
||||
}
|
||||
|
||||
private async isSipEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||
return stdout.toLowerCase().includes('enabled')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getWeChatPid(): Promise<number> {
|
||||
const commands: [string, string[]][] = [
|
||||
['/usr/bin/pgrep', ['-x', 'WeChat']],
|
||||
['/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']]
|
||||
]
|
||||
for (const [command, args] of commands) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(command, args)
|
||||
const pids = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((value) => Number.parseInt(value.trim(), 10))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
if (pids.length) return Math.max(...pids)
|
||||
} catch {
|
||||
// Try the next process lookup strategy.
|
||||
}
|
||||
}
|
||||
throw new Error('未找到微信主进程,请先启动并登录微信')
|
||||
}
|
||||
|
||||
private parseHelperOutput(output: string): DatabaseKeyResult {
|
||||
const payloads: Record<string, unknown>[] = []
|
||||
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
||||
try {
|
||||
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
||||
} catch {
|
||||
// Ignore helper progress that is not JSON.
|
||||
}
|
||||
}
|
||||
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
||||
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
||||
if (!isValidDatabaseKey(rawKey)) {
|
||||
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
||||
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
||||
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
|
||||
const code = parsedError?.[1]?.toUpperCase()
|
||||
const detail = parsedError?.[2]?.trim() || ''
|
||||
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error:
|
||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||
'建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: rawError || '密钥工具未返回有效的 64 位密钥'
|
||||
}
|
||||
}
|
||||
return { success: true, key: rawKey }
|
||||
}
|
||||
|
||||
async autoGetDbKey(
|
||||
onStatus?: (message: string) => void,
|
||||
timeoutMs = 60_000
|
||||
): Promise<DatabaseKeyResult> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
|
||||
}
|
||||
if (await this.isSipEnabled()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
onStatus?.('正在查找微信进程...')
|
||||
const pid = await this.getWeChatPid()
|
||||
const helperPath = this.getHelperPath()
|
||||
const waitMs = Math.max(30_000, timeoutMs)
|
||||
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
|
||||
onStatus?.('正在请求管理员授权...')
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set timeoutSec to ${timeoutSeconds}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
'set outText to do shell script cmd with administrator privileges',
|
||||
'end timeout',
|
||||
'return "OK::" & outText',
|
||||
'on error errMsg number errNum',
|
||||
'return "ERR::" & errNum & "::" & errMsg',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('授权后请保持微信登录界面 并点击登录微信...')
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/osascript',
|
||||
scriptLines.flatMap((line) => ['-e', line]),
|
||||
{ timeout: waitMs + 20_000 }
|
||||
)
|
||||
const output = String(stdout).trim()
|
||||
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
|
||||
if (output.startsWith('ERR::')) {
|
||||
return {
|
||||
success: false,
|
||||
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
|
||||
}
|
||||
}
|
||||
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
||||
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||
return result
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async autoGetImageKey(
|
||||
accountPath?: string,
|
||||
onStatus?: (message: string) => void,
|
||||
wxid?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
try {
|
||||
onStatus?.('正在从缓存目录扫描图片密钥...')
|
||||
const codes = this.collectKvcommCodes(accountPath)
|
||||
if (codes.length === 0) {
|
||||
return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' }
|
||||
}
|
||||
|
||||
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
||||
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
|
||||
|
||||
for (const candidateAccountPath of accountPathCandidates) {
|
||||
if (!fs.existsSync(candidateAccountPath)) continue
|
||||
const template = this.findTemplateData(candidateAccountPath, 32)
|
||||
if (!template.ciphertext) continue
|
||||
|
||||
const orderedWxids: string[] = []
|
||||
this.pushAccountIdCandidates(orderedWxids, path.basename(candidateAccountPath))
|
||||
for (const candidate of wxidCandidates)
|
||||
this.pushAccountIdCandidates(orderedWxids, candidate)
|
||||
|
||||
onStatus?.(`正在校验候选 wxid(${orderedWxids.length} 个)...`)
|
||||
for (const candidateWxid of orderedWxids) {
|
||||
for (const code of codes) {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||
onStatus?.(`图片密钥获取成功 (wxid: ${candidateWxid})`)
|
||||
return { success: true, xorKey, aesKey, verified: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackWxid = wxidCandidates[0]
|
||||
const fallbackCode = codes[0]
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onStatus?.(`图片密钥已计算 (wxid: ${fallbackWxid})`)
|
||||
return { success: true, xorKey, aesKey, verified: false }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private collectKvcommCodes(accountPath?: string): number[] {
|
||||
const codeSet = new Set<number>()
|
||||
const pattern = /^key_(\d+)_.+\.statistic$/i
|
||||
for (const kvcommDir of this.getKvcommCandidates(accountPath)) {
|
||||
if (!fs.existsSync(kvcommDir)) continue
|
||||
try {
|
||||
for (const file of fs.readdirSync(kvcommDir)) {
|
||||
const match = file.match(pattern)
|
||||
if (!match) continue
|
||||
const code = Number(match[1])
|
||||
if (Number.isFinite(code) && code > 0 && code <= 0xffffffff) codeSet.add(code)
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
return Array.from(codeSet)
|
||||
}
|
||||
|
||||
private getKvcommCandidates(accountPath?: string): string[] {
|
||||
const home = app.getPath('home')
|
||||
const candidates = new Set<string>([
|
||||
path.join(
|
||||
home,
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm'
|
||||
),
|
||||
path.join(
|
||||
home,
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm'
|
||||
),
|
||||
path.join(
|
||||
home,
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/net/kvcomm'
|
||||
),
|
||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat/net/kvcomm')
|
||||
])
|
||||
|
||||
const normalized = String(accountPath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/, '')
|
||||
const marker = '/xwechat_files'
|
||||
const markerIndex = normalized.indexOf(marker)
|
||||
if (markerIndex >= 0) {
|
||||
candidates.add(`${normalized.slice(0, markerIndex)}/app_data/net/kvcomm`)
|
||||
}
|
||||
|
||||
const newPathMatch = normalized.match(
|
||||
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/
|
||||
)
|
||||
if (newPathMatch) {
|
||||
candidates.add(`${newPathMatch[1]}/net/kvcomm`)
|
||||
candidates.add(`${newPathMatch[1]}/xwechat/net/kvcomm`)
|
||||
}
|
||||
|
||||
return Array.from(candidates)
|
||||
}
|
||||
|
||||
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
this.pushAccountIdCandidates(candidates, wxidParam)
|
||||
|
||||
const normalized = String(accountPath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/, '')
|
||||
if (normalized) {
|
||||
this.pushAccountIdCandidates(candidates, path.basename(normalized))
|
||||
const root = this.resolveXwechatRootFromPath(normalized)
|
||||
if (root && fs.existsSync(root)) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const entryPath = path.join(root, entry.name)
|
||||
if (this.isAccountDirPath(entryPath))
|
||||
this.pushAccountIdCandidates(candidates, entry.name)
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) candidates.push('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
private collectAccountPathCandidates(accountPath?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const push = (value?: string): void => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized && !candidates.includes(normalized)) candidates.push(normalized)
|
||||
}
|
||||
|
||||
push(accountPath)
|
||||
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||
if (root && fs.existsSync(root)) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const entryPath = path.join(root, entry.name)
|
||||
if (this.isAccountDirPath(entryPath) && this.isReasonableAccountId(entry.name))
|
||||
push(entryPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||
const normalized = String(accountPath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/, '')
|
||||
if (!normalized) return null
|
||||
const marker = '/xwechat_files'
|
||||
const markerIndex = normalized.indexOf(marker)
|
||||
if (markerIndex >= 0) return normalized.slice(0, markerIndex + marker.length)
|
||||
const newPathMatch = normalized.match(
|
||||
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/
|
||||
)
|
||||
return newPathMatch ? newPathMatch[1] : null
|
||||
}
|
||||
|
||||
private isAccountDirPath(entryPath: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(entryPath, 'db_storage')) ||
|
||||
fs.existsSync(path.join(entryPath, 'msg')) ||
|
||||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image')) ||
|
||||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image2'))
|
||||
)
|
||||
}
|
||||
|
||||
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||
const raw = String(value || '').trim()
|
||||
if (!this.isReasonableAccountId(raw)) return
|
||||
for (const candidate of [raw, this.normalizeAccountId(raw)]) {
|
||||
if (candidate && !candidates.includes(candidate) && this.isReasonableAccountId(candidate)) {
|
||||
candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeAccountId(value: string): string {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private isReasonableAccountId(value: string): boolean {
|
||||
const lowered = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!lowered || lowered.includes('/') || lowered.includes('\\')) return false
|
||||
return !['xwechat_files', 'all_users', 'backup', 'wmpf', 'app_data'].includes(lowered)
|
||||
}
|
||||
|
||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||
const xorKey = code & 0xff
|
||||
const aesKey = crypto
|
||||
.createHash('md5')
|
||||
.update(`${code}${this.normalizeAccountId(wxid)}`)
|
||||
.digest('hex')
|
||||
.substring(0, 16)
|
||||
return { xorKey, aesKey }
|
||||
}
|
||||
|
||||
private findTemplateData(userDir: string, limit = 32): { ciphertext: Buffer | null } {
|
||||
const magic = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
const files: string[] = []
|
||||
const collect = (dir: string): void => {
|
||||
if (files.length >= limit) return
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (files.length >= limit) break
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) collect(full)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) files.push(full)
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
}
|
||||
collect(userDir)
|
||||
files.sort((a, b) => {
|
||||
try {
|
||||
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const data = fs.readFileSync(file)
|
||||
if (data.length >= 0x1f && data.subarray(0, 6).equals(magic)) {
|
||||
return { ciphertext: data.subarray(0x0f, 0x1f) }
|
||||
}
|
||||
} catch {
|
||||
// Try the next file.
|
||||
}
|
||||
}
|
||||
return { ciphertext: null }
|
||||
}
|
||||
|
||||
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||
try {
|
||||
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-128-ecb',
|
||||
Buffer.from(aesKey, 'ascii').subarray(0, 16),
|
||||
null
|
||||
)
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
if (dec[0] === 0xff && dec[1] === 0xd8 && dec[2] === 0xff) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4e && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
type TextContent = { type: 'text'; content: string }
|
||||
type VoiceContent = { type: 'voice'; duration?: number }
|
||||
type LocationContent = {
|
||||
type: 'location'
|
||||
poiname?: string
|
||||
label?: string
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
type ShareContent = {
|
||||
type: 'share'
|
||||
title: string
|
||||
des?: string
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
md5?: string
|
||||
datName?: string
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
url?: string
|
||||
thumbUrl?: string
|
||||
encryptUrl?: string
|
||||
aeskey?: string
|
||||
}
|
||||
type QuoteContent = {
|
||||
type: 'quote'
|
||||
title?: string
|
||||
content?: string
|
||||
sender?: string
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
|
||||
export type ParsedContent =
|
||||
| TextContent
|
||||
| VoiceContent
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
| UnknownContent
|
||||
|
||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { type: 'unknown', raw: content || '' }
|
||||
}
|
||||
|
||||
const normalized = content.trim()
|
||||
|
||||
switch (messageType) {
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 47:
|
||||
return parseStickerMessage(normalized)
|
||||
case 48:
|
||||
return parseLocationMessage(normalized)
|
||||
case 49:
|
||||
return parseShareMessage(normalized)
|
||||
case 50:
|
||||
return parseVoipMessage(normalized)
|
||||
case 10000:
|
||||
case 10002:
|
||||
return parseSystemMessage(normalized)
|
||||
default:
|
||||
return { type: 'text', content: normalized }
|
||||
}
|
||||
}
|
||||
|
||||
function parseSystemMessage(content: string): ParsedContent {
|
||||
const stripped = stripChatroomPrefix(content)
|
||||
const decoded = decodeXmlEntities(stripped)
|
||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||
if (delChatroomMemberText) {
|
||||
return {
|
||||
type: 'system',
|
||||
content: normalizeSystemText(delChatroomMemberText),
|
||||
raw: content
|
||||
}
|
||||
}
|
||||
const plainText =
|
||||
extractXmlNodeText(decoded, 'plain') ||
|
||||
extractXmlNodeText(decoded, 'text') ||
|
||||
extractXmlNodeText(decoded, 'title') ||
|
||||
extractXmlValue(decoded, 'plain') ||
|
||||
extractXmlValue(decoded, 'text') ||
|
||||
extractXmlValue(decoded, 'title') ||
|
||||
''
|
||||
|
||||
const normalized = normalizeSystemText(plainText || fallbackSystemText(decoded))
|
||||
return {
|
||||
type: 'system',
|
||||
content: normalized || '[系统消息]',
|
||||
raw: content
|
||||
}
|
||||
}
|
||||
|
||||
function parseImageMessage(content: string): ParsedContent {
|
||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||
let aeskey =
|
||||
extractXmlAttribute(content, 'img', 'aeskey') || extractXmlValue(content, 'aeskey') || undefined
|
||||
const encrypVerStr =
|
||||
extractXmlAttribute(content, 'img', 'encrypver') || extractXmlValue(content, 'encrypver') || '0'
|
||||
let datName = ''
|
||||
|
||||
// 如果 XML 格式解析失败,尝试 JSON 格式
|
||||
if (!md5) {
|
||||
try {
|
||||
const json = JSON.parse(content)
|
||||
// 可能是引用消息格式 { type: "...", content: "md5", ... }
|
||||
if (
|
||||
json.content &&
|
||||
typeof json.content === 'string' &&
|
||||
/^[a-f0-9]{32}$/i.test(json.content)
|
||||
) {
|
||||
md5 = json.content
|
||||
} else if (json.md5 && typeof json.md5 === 'string') {
|
||||
md5 = json.md5
|
||||
}
|
||||
if (json.datName && typeof json.datName === 'string') {
|
||||
datName = json.datName
|
||||
}
|
||||
if (json.imageDatName && typeof json.imageDatName === 'string') {
|
||||
datName = json.imageDatName
|
||||
}
|
||||
// 尝试从其他字段获取 aeskey
|
||||
if (!aeskey && json.aeskey) {
|
||||
aeskey = json.aeskey
|
||||
}
|
||||
if (!aeskey && json.aeskey_v2) {
|
||||
aeskey = json.aeskey_v2
|
||||
}
|
||||
} catch {
|
||||
// 不是 JSON 格式
|
||||
}
|
||||
}
|
||||
|
||||
const encrypVer = parseInt(encrypVerStr, 10)
|
||||
|
||||
if (!md5 && !datName) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'image', md5: md5 || undefined, datName: datName || undefined, aeskey, encrypVer }
|
||||
}
|
||||
|
||||
function parseStickerMessage(content: string): ParsedContent {
|
||||
// 表情包消息可能包含 md5 或 url
|
||||
const md5 =
|
||||
extractXmlAttribute(content, 'emoji', 'md5') ||
|
||||
extractXmlValue(content, 'md5') ||
|
||||
extractXmlAttribute(content, 'sticker', 'md5') ||
|
||||
extractLooseHexMd5(content) ||
|
||||
''
|
||||
const url = decodeXmlUrl(
|
||||
extractXmlValue(content, 'url') ||
|
||||
extractXmlAttribute(content, 'emoji', 'cdnurl') ||
|
||||
extractXmlAttribute(content, 'emoji', 'url') ||
|
||||
extractXmlAttribute(content, 'emoji', 'thumburl') ||
|
||||
extractLooseAttribute(content, 'cdnurl') ||
|
||||
extractLooseAttribute(content, 'url') ||
|
||||
extractLooseAttribute(content, 'thumburl') ||
|
||||
''
|
||||
)
|
||||
const thumbUrl = decodeXmlUrl(
|
||||
extractXmlAttribute(content, 'emoji', 'thumburl') || extractLooseAttribute(content, 'thumburl')
|
||||
)
|
||||
const encryptUrl = decodeXmlUrl(
|
||||
extractXmlAttribute(content, 'emoji', 'encrypturl') ||
|
||||
extractLooseAttribute(content, 'encrypturl')
|
||||
)
|
||||
const aeskey =
|
||||
extractXmlAttribute(content, 'emoji', 'aeskey') ||
|
||||
extractLooseAttribute(content, 'aeskey') ||
|
||||
undefined
|
||||
|
||||
if (!md5 && !url && !thumbUrl && !encryptUrl) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'sticker',
|
||||
md5,
|
||||
url: url || thumbUrl || undefined,
|
||||
thumbUrl: thumbUrl || undefined,
|
||||
encryptUrl: encryptUrl || undefined,
|
||||
aeskey
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStickerMessageFromRow(
|
||||
row: Record<string, unknown>,
|
||||
content: string
|
||||
): ParsedContent {
|
||||
const supplementalPayload = [
|
||||
content,
|
||||
pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']),
|
||||
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']),
|
||||
decodeSupplementalPayload(
|
||||
pickRowString(row, [
|
||||
'packed_info_data',
|
||||
'packed_info',
|
||||
'packedInfoData',
|
||||
'packedInfo',
|
||||
'PackedInfoData',
|
||||
'PackedInfo',
|
||||
'WCDB_CT_packed_info_data',
|
||||
'WCDB_CT_packed_info'
|
||||
])
|
||||
),
|
||||
decodeSupplementalPayload(pickRowString(row, ['reserved0', 'Reserved0', 'WCDB_CT_reserved0']))
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const directMd5 = normalizeMd5(pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']))
|
||||
const directUrl = decodeXmlUrl(
|
||||
String(
|
||||
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']) || ''
|
||||
)
|
||||
)
|
||||
const parsed = parseStickerMessage(supplementalPayload)
|
||||
|
||||
if (parsed.type === 'sticker') {
|
||||
return {
|
||||
...parsed,
|
||||
md5: parsed.md5 || directMd5,
|
||||
url: parsed.url || directUrl || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (directMd5 || directUrl) {
|
||||
return {
|
||||
type: 'sticker',
|
||||
md5: directMd5,
|
||||
url: directUrl || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseCardMessage(content: string): ParsedContent {
|
||||
const username =
|
||||
extractXmlValue(content, 'username') || extractXmlValue(content, 'cardUsername') || ''
|
||||
const nickname =
|
||||
extractXmlValue(content, 'nickname') || extractXmlValue(content, 'cardNickname') || ''
|
||||
const avatarUrl =
|
||||
extractXmlValue(content, 'avatarUrl') ||
|
||||
extractXmlValue(content, 'smallHeadImgUrl') ||
|
||||
undefined
|
||||
|
||||
if (!username && !nickname) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'card', username, nickname, avatarUrl }
|
||||
}
|
||||
|
||||
function parseLocationMessage(content: string): ParsedContent {
|
||||
const poiname = extractXmlValue(content, 'poiname') || extractXmlValue(content, 'poiName') || ''
|
||||
const label = extractXmlValue(content, 'label') || ''
|
||||
|
||||
const latStr =
|
||||
extractXmlAttribute(content, 'location', 'x') ||
|
||||
extractXmlAttribute(content, 'location', 'latitude') ||
|
||||
'0'
|
||||
const lngStr =
|
||||
extractXmlAttribute(content, 'location', 'y') ||
|
||||
extractXmlAttribute(content, 'location', 'longitude') ||
|
||||
'0'
|
||||
|
||||
const lat = parseFloat(latStr)
|
||||
const lng = parseFloat(lngStr)
|
||||
|
||||
if (!poiname && lat === 0 && lng === 0) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'location', poiname, label, lat, lng }
|
||||
}
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||
const quote = parseQuoteMessage(content)
|
||||
const title = extractXmlValue(content, 'title') || undefined
|
||||
return {
|
||||
type: 'quote',
|
||||
title,
|
||||
content: title,
|
||||
quotedContent: quote.content || '[引用消息]',
|
||||
quotedSender: quote.sender,
|
||||
quotedType: quote.type
|
||||
}
|
||||
}
|
||||
|
||||
const title = extractXmlValue(content, 'title') || ''
|
||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
||||
const url = extractXmlValue(content, 'url') || ''
|
||||
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
|
||||
const typeVal = extractXmlValue(content, 'type') || ''
|
||||
|
||||
if (!title && !url) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||
const referMsgStart = content.indexOf('<refermsg>')
|
||||
const referMsgEnd = content.indexOf('</refermsg>')
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
|
||||
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
|
||||
const sender =
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) ||
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) ||
|
||||
undefined
|
||||
const referContent = extractXmlValue(referMsgXml, 'content')
|
||||
const referType = extractXmlValue(referMsgXml, 'type')
|
||||
|
||||
switch (referType) {
|
||||
case '1':
|
||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||
case '3':
|
||||
return { sender, content: '[图片]', type: referType }
|
||||
case '34':
|
||||
return { sender, content: '[语音]', type: referType }
|
||||
case '43':
|
||||
return { sender, content: '[视频]', type: referType }
|
||||
case '47':
|
||||
return { sender, content: '[表情]', type: referType }
|
||||
case '49':
|
||||
return {
|
||||
sender,
|
||||
content: extractXmlValue(referMsgXml, 'title') || '[分享消息]',
|
||||
type: referType
|
||||
}
|
||||
default:
|
||||
return {
|
||||
sender,
|
||||
content: sanitizeQuotedContent(referContent) || '[引用消息]',
|
||||
type: referType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractAppMsgType(content: string): string {
|
||||
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||
if (!appmsgMatch) return extractXmlValue(content, 'type')
|
||||
const inner = appmsgMatch[1]
|
||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||
return typeMatch?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function sanitizeQuotedContent(content: string): string {
|
||||
const decoded = String(content || '')
|
||||
.replace(/^wxid_[^:\n]+:\s*/i, '')
|
||||
.trim()
|
||||
if (/^(wxid_[\w-]+|[a-z][a-z0-9_-]{5,})$/i.test(decoded)) return ''
|
||||
return decoded
|
||||
}
|
||||
|
||||
function stripChatroomPrefix(content: string): string {
|
||||
return String(content || '')
|
||||
.replace(/^[0-9a-z_-]+@chatroom:\s*/i, '')
|
||||
.replace(/^wxid_[^:\n]+:\s*/i, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeSystemText(content: string): string {
|
||||
return String(content || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s+([,.;!?])/g, '$1')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function parseVoipMessage(content: string): ParsedContent {
|
||||
const roomTypeStr = extractXmlValue(content, 'room_type')
|
||||
const msg = extractXmlValue(content, 'msg') || ''
|
||||
const durationStr = extractXmlValue(content, 'duration') || '0'
|
||||
|
||||
const roomType = roomTypeStr ? parseInt(roomTypeStr, 10) : 0
|
||||
const duration = parseInt(durationStr, 10)
|
||||
|
||||
let status = msg
|
||||
if (!status) {
|
||||
status = roomType === 1 ? '[视频通话]' : '[语音通话]'
|
||||
}
|
||||
|
||||
return { type: 'voip', duration, status, roomType }
|
||||
}
|
||||
|
||||
function extractXmlValue(xml: string, tagName: string): string {
|
||||
const patterns = [
|
||||
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
|
||||
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
|
||||
new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i'),
|
||||
new RegExp(`${tagName}=["']([^"']*)["']`, 'i')
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = xml.match(pattern)
|
||||
if (match && match[1]) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
||||
const match = xml.match(pattern)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractLooseAttribute(content: string, attrName: string): string {
|
||||
const quoted = new RegExp(`${attrName}\\s*=\\s*["']([^"']+)["']`, 'i').exec(content)
|
||||
if (quoted?.[1]) return quoted[1].trim()
|
||||
const unquoted = new RegExp(`${attrName}\\s*=\\s*([^"']+?)(?=\\s|/|>)`, 'i').exec(content)
|
||||
return unquoted?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function decodeXmlUrl(value: string): string {
|
||||
const normalized = String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
if (!normalized) return ''
|
||||
if (!normalized.includes('%')) return normalized
|
||||
try {
|
||||
return decodeURIComponent(normalized)
|
||||
} catch {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return String(value || '')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
function extractXmlNodeText(xml: string, tagName: string): string {
|
||||
const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml)
|
||||
if (!match?.[1]) return ''
|
||||
return normalizeSystemText(
|
||||
decodeXmlEntities(
|
||||
match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' ')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function fallbackSystemText(xml: string): string {
|
||||
return normalizeSystemText(
|
||||
decodeXmlEntities(
|
||||
String(xml || '')
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function extractDelChatroomMemberText(xml: string): string {
|
||||
if (!/<sysmsg[^>]+delchatroommember/i.test(xml)) return ''
|
||||
const plainMatch = /<plain[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/plain>/i.exec(xml)
|
||||
if (plainMatch?.[1]) return plainMatch[1].trim()
|
||||
const textMatch = /<text[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/text>/i.exec(xml)
|
||||
if (textMatch?.[1]) return textMatch[1].trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeMd5(value: unknown): string | undefined {
|
||||
const md5 = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
|
||||
}
|
||||
|
||||
function extractLooseHexMd5(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const match =
|
||||
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
|
||||
/([a-fA-F0-9]{32})/i.exec(content)
|
||||
return normalizeMd5(match?.[1] || match?.[0])
|
||||
}
|
||||
|
||||
function decodeSupplementalPayload(raw: unknown): string {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string' && !/^[a-fA-F0-9]+$/.test(raw.trim())) return raw.trim()
|
||||
const buffer = decodePackedInfo(raw)
|
||||
if (!buffer || buffer.length === 0) return ''
|
||||
const decoded = buffer.toString('utf-8')
|
||||
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
|
||||
if (replacementCount < decoded.length * 0.2) {
|
||||
return decoded.replace(/\uFFFD/g, '')
|
||||
}
|
||||
return Array.from(buffer)
|
||||
.map((byte) => (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : ' '))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function parseImageDatNameFromRow(row: Record<string, unknown>): string | undefined {
|
||||
const packed = pickRowString(row, [
|
||||
'packed_info_data',
|
||||
'packed_info',
|
||||
'packedInfoData',
|
||||
'packedInfo',
|
||||
'PackedInfoData',
|
||||
'PackedInfo',
|
||||
'WCDB_CT_packed_info_data',
|
||||
'WCDB_CT_packed_info',
|
||||
'WCDB_CT_PackedInfoData',
|
||||
'WCDB_CT_PackedInfo'
|
||||
])
|
||||
const buffer = decodePackedInfo(packed)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
|
||||
const printable = Array.from(buffer).map((byte) => (byte >= 0x20 && byte <= 0x7e ? byte : 0x20))
|
||||
const text = Buffer.from(printable).toString('utf-8')
|
||||
const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text)
|
||||
if (match?.[1]) return match[1].toLowerCase()
|
||||
const hexMatch = /([0-9a-fA-F]{16,})/.exec(text)
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||
const foundKey = Object.keys(row).find(
|
||||
(candidate) => candidate.toLowerCase() === key.toLowerCase()
|
||||
)
|
||||
if (foundKey) return row[foundKey]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodePackedInfo(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 === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
try {
|
||||
return Buffer.from(trimmed, 'hex')
|
||||
} catch {
|
||||
// Try base64 below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Buffer.from(trimmed, 'base64')
|
||||
} catch {
|
||||
// Unsupported packed_info encoding.
|
||||
}
|
||||
}
|
||||
if (typeof raw === 'object' && raw && Array.isArray((raw as { data?: unknown }).data)) {
|
||||
return Buffer.from((raw as { data: number[] }).data)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
function prependPath(values: string[]): void {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const existing = process.env.PATH || ''
|
||||
const next = Array.from(new Set(values.filter(Boolean))).join(path.delimiter)
|
||||
process.env.PATH = next ? `${next}${path.delimiter}${existing}` : existing
|
||||
process.env.Path = process.env.PATH
|
||||
}
|
||||
|
||||
try {
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const resourceRoots = [
|
||||
path.join(process.cwd(), 'resources'),
|
||||
path.join(process.cwd(), 'resources', 'resources'),
|
||||
path.join(process.resourcesPath || '', 'resources'),
|
||||
process.resourcesPath || ''
|
||||
].filter((value, index, list) => value && list.indexOf(value) === index && fs.existsSync(value))
|
||||
|
||||
const resourcesRoot = resourceRoots[0] || path.join(process.cwd(), 'resources')
|
||||
const dllDirs = resourceRoots.flatMap((root) => [
|
||||
root,
|
||||
path.join(root, 'wcdb', 'win32', archDir),
|
||||
path.join(root, 'wcdb', 'win32', 'x64'),
|
||||
path.join(root, 'key', 'win32', archDir),
|
||||
path.join(root, 'key', 'win32', 'x64'),
|
||||
path.join(root, 'runtime', 'win32')
|
||||
])
|
||||
|
||||
process.env.WCDB_RESOURCES_PATH = process.env.WCDB_RESOURCES_PATH || resourcesRoot
|
||||
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
|
||||
} catch (error) {
|
||||
console.error('[WechatExplorer] failed to enforce local DLL priority:', error)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { app, nativeImage } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import type {
|
||||
GeneratedReportRecord,
|
||||
DeleteGeneratedReportResult,
|
||||
ReportAssetStatus,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
} from '../shared/report-history'
|
||||
|
||||
const REPORTS_DIR = 'reports'
|
||||
|
||||
const getReportsRoot = (): string => path.join(app.getPath('userData'), REPORTS_DIR)
|
||||
|
||||
const pad2 = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
const safeSegment = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
// Strip Windows-reserved filename characters and ASCII control chars.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.slice(0, 48) || 'report'
|
||||
|
||||
const parseDataUrl = (dataUrl: string): Buffer | null => {
|
||||
const match = /^data:image\/png;base64,(.+)$/i.exec(dataUrl)
|
||||
if (!match) return null
|
||||
return Buffer.from(match[1], 'base64')
|
||||
}
|
||||
|
||||
const exists = async (filePath?: string): Promise<boolean> => {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
return stat.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const fileStatus = async (filePath?: string): Promise<ReportAssetStatus> =>
|
||||
(await exists(filePath)) ? 'ready' : 'missing'
|
||||
|
||||
const readPngAsDataUrl = async (filePath?: string): Promise<string | undefined> => {
|
||||
if (!filePath || !(await exists(filePath))) return undefined
|
||||
const content = await fs.readFile(filePath)
|
||||
return `data:image/png;base64,${content.toString('base64')}`
|
||||
}
|
||||
|
||||
const readPngSize = async (
|
||||
filePath?: string
|
||||
): Promise<{ width: number; height: number } | undefined> => {
|
||||
if (!filePath || !(await exists(filePath))) return undefined
|
||||
const image = nativeImage.createFromPath(filePath)
|
||||
if (image.isEmpty()) return undefined
|
||||
const size = image.getSize()
|
||||
return size.width && size.height ? size : undefined
|
||||
}
|
||||
|
||||
const readFileSize = async (filePath?: string): Promise<number | undefined> => {
|
||||
if (!filePath) return undefined
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
return stat.isFile() ? stat.size : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeRecord = async (
|
||||
record: GeneratedReportRecord,
|
||||
jsonPath: string
|
||||
): Promise<GeneratedReportRecord> => {
|
||||
const htmlStatus = await fileStatus(record.htmlPath)
|
||||
const pngStatus = await fileStatus(record.pngPath)
|
||||
return {
|
||||
...record,
|
||||
jsonPath,
|
||||
htmlStatus,
|
||||
pngStatus,
|
||||
generatedImage: await readPngAsDataUrl(record.pngPath),
|
||||
imageSize: await readPngSize(record.pngPath),
|
||||
fileSize: {
|
||||
html: await readFileSize(record.htmlPath),
|
||||
png: await readFileSize(record.pngPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const walkJsonFiles = async (directory: string): Promise<string[]> => {
|
||||
try {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true })
|
||||
const children = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return walkJsonFiles(entryPath)
|
||||
return entry.isFile() && entry.name.endsWith('.json') ? [entryPath] : []
|
||||
})
|
||||
)
|
||||
return children.flat()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGeneratedReports(): Promise<ReportHistoryResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
const records = await Promise.all(
|
||||
jsonFiles.map(async (jsonPath) => {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
return normalizeRecord(JSON.parse(content) as GeneratedReportRecord, jsonPath)
|
||||
} catch (error) {
|
||||
console.warn(`[ReportHistory] skip invalid report record: ${jsonPath}`, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
const reports = records.filter((record): record is GeneratedReportRecord => Boolean(record))
|
||||
reports.sort((left, right) => Date.parse(right.generatedAt) - Date.parse(left.generatedAt))
|
||||
return { success: true, reports }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGeneratedReport(
|
||||
request: SaveGeneratedReportRequest
|
||||
): Promise<SaveGeneratedReportResult> {
|
||||
try {
|
||||
const generatedAtDate = new Date(request.generatedAt)
|
||||
const timestamp = Number.isFinite(generatedAtDate.getTime()) ? generatedAtDate : new Date()
|
||||
const year = String(timestamp.getFullYear())
|
||||
const month = pad2(timestamp.getMonth() + 1)
|
||||
const directory = path.join(getReportsRoot(), year, month)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
|
||||
const id = `report_${timestamp.getTime()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const baseName = `${id}_${safeSegment(request.contactName)}`
|
||||
const htmlPath = path.join(directory, `${baseName}.html`)
|
||||
const pngPath = path.join(directory, `${baseName}.png`)
|
||||
const jsonPath = path.join(directory, `${baseName}.json`)
|
||||
|
||||
let savedHtmlPath: string | undefined
|
||||
if (request.htmlPath && (await exists(request.htmlPath))) {
|
||||
await fs.copyFile(request.htmlPath, htmlPath)
|
||||
savedHtmlPath = htmlPath
|
||||
}
|
||||
|
||||
let savedPngPath: string | undefined
|
||||
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
|
||||
if (imageBuffer) {
|
||||
await fs.writeFile(pngPath, imageBuffer)
|
||||
savedPngPath = pngPath
|
||||
} else if (request.pngPath && (await exists(request.pngPath))) {
|
||||
await fs.copyFile(request.pngPath, pngPath)
|
||||
savedPngPath = pngPath
|
||||
}
|
||||
|
||||
const record: GeneratedReportRecord = {
|
||||
id,
|
||||
contactId: request.contactId,
|
||||
contactName: request.contactName,
|
||||
contactAvatar: request.contactAvatar,
|
||||
dateRange: request.dateRange,
|
||||
messageCount: request.messageCount,
|
||||
generatedAt: timestamp.toISOString(),
|
||||
reportDate: `${year}-${month}-${pad2(timestamp.getDate())}`,
|
||||
htmlPath: savedHtmlPath,
|
||||
pngPath: savedPngPath,
|
||||
jsonPath,
|
||||
htmlStatus: savedHtmlPath ? 'ready' : 'missing',
|
||||
pngStatus: savedPngPath ? 'ready' : 'missing',
|
||||
imageSize: await readPngSize(savedPngPath),
|
||||
duration: request.duration,
|
||||
modelName: request.modelName,
|
||||
tokenUsage: request.tokenUsage,
|
||||
fileSize: {
|
||||
html: await readFileSize(savedHtmlPath),
|
||||
png: await readFileSize(savedPngPath)
|
||||
},
|
||||
generationLogs: request.generationLogs
|
||||
}
|
||||
|
||||
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
|
||||
return {
|
||||
success: true,
|
||||
record: {
|
||||
...record,
|
||||
generatedImage: savedPngPath ? await readPngAsDataUrl(savedPngPath) : undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGeneratedReport(
|
||||
reportId: string
|
||||
): Promise<DeleteGeneratedReportResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
for (const jsonPath of jsonFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
const record = JSON.parse(content) as GeneratedReportRecord
|
||||
if (record.id !== reportId) continue
|
||||
|
||||
const paths = [record.htmlPath, record.pngPath, jsonPath].filter(
|
||||
(filePath): filePath is string => Boolean(filePath)
|
||||
)
|
||||
await Promise.all(
|
||||
paths.map(async (filePath) => {
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
return { success: true, deletedId: reportId }
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ReportHistory] skip invalid report record while deleting: ${jsonPath}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return { success: false, error: '未找到要删除的日报记录' }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)))
|
||||
}
|
||||
|
||||
export function getResourceRoots(): string[] {
|
||||
const appPath = app.getAppPath()
|
||||
const appPathDir = dirname(appPath)
|
||||
const execDir = dirname(process.execPath)
|
||||
|
||||
return unique([
|
||||
process.env.WECHATEXPLORER_RESOURCES_PATH || '',
|
||||
join(process.cwd(), 'resources'),
|
||||
join(process.cwd(), 'resources', 'resources'),
|
||||
join(process.resourcesPath || '', 'resources'),
|
||||
process.resourcesPath || '',
|
||||
join(appPath, 'resources'),
|
||||
join(appPathDir, 'resources'),
|
||||
appPathDir,
|
||||
join(execDir, 'resources'),
|
||||
join(dirname(execDir), 'Resources', 'resources'),
|
||||
join(dirname(execDir), 'Resources')
|
||||
]).filter((root) => existsSync(root))
|
||||
}
|
||||
|
||||
export function getResourceCandidates(relativePath: string): string[] {
|
||||
return unique(getResourceRoots().map((root) => join(root, relativePath)))
|
||||
}
|
||||
|
||||
export function findResource(relativePath: string): string | null {
|
||||
return getResourceCandidates(relativePath).find((candidate) => existsSync(candidate)) || null
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Safe logging for Electron child processes whose stdout/stderr pipes can be
|
||||
// closed by the parent (electron-vite). Plain console.error throws EPIPE
|
||||
// against a closed pipe, which crashes the IPC handler. Wrap writes so any
|
||||
// pipe error is swallowed.
|
||||
type SafeConsoleMethod = (...args: unknown[]) => void
|
||||
|
||||
function makeSafe(method: SafeConsoleMethod): SafeConsoleMethod {
|
||||
return (...args: unknown[]) => {
|
||||
try {
|
||||
method(...args)
|
||||
} catch {
|
||||
// Swallow EPIPE / ERR_STREAM_DESTROYED; logging must never crash the app.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const safeLog = makeSafe(console.log.bind(console))
|
||||
export const safeWarn = makeSafe(console.warn.bind(console))
|
||||
export const safeError = makeSafe(console.error.bind(console))
|
||||
|
||||
export function installSafeConsole(): void {
|
||||
console.log = safeLog
|
||||
console.warn = safeWarn
|
||||
console.error = safeError
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AIConnectionTestResult,
|
||||
AIProviderConfig,
|
||||
AIProviderListResult,
|
||||
AIProviderSummary,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
LegacyAIConfig
|
||||
} from '../../shared/ai-provider'
|
||||
import { AIProviderKeyStore } from '../ai-provider-key-store'
|
||||
|
||||
interface AIProviderMetadataFile {
|
||||
version: 1
|
||||
defaultProviderId?: string
|
||||
providers: Array<Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>>
|
||||
}
|
||||
|
||||
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
|
||||
type AIMessage = { role: string; content: string | AIMessagePart[] }
|
||||
type AIRequestResult = {
|
||||
data: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
}
|
||||
interface OpenAIResponsePayload {
|
||||
error?: { message?: string }
|
||||
choices?: Array<{ message?: { content?: string } }>
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
||||
}
|
||||
interface AnthropicResponsePayload {
|
||||
error?: { message?: string }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
usage?: { input_tokens?: number; output_tokens?: number }
|
||||
}
|
||||
|
||||
export class AIProviderService {
|
||||
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||
|
||||
list(): AIProviderListResult {
|
||||
this.ensureEnvironmentMigration()
|
||||
try {
|
||||
const data = this.readMetadata()
|
||||
return {
|
||||
success: true,
|
||||
defaultProviderId: data.defaultProviderId,
|
||||
providers: data.providers.map((provider) =>
|
||||
this.toSummary(provider, data.defaultProviderId)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return { success: false, providers: [], error: 'AI Provider 配置无法读取' }
|
||||
}
|
||||
}
|
||||
|
||||
getRuntimeConfig(): AIRuntimeModelConfig {
|
||||
const result = this.list()
|
||||
const provider =
|
||||
result.providers.find((item) => item.id === result.defaultProviderId) || result.providers[0]
|
||||
const model = provider?.models.find((item) => item.id === provider.defaultModel)
|
||||
return {
|
||||
providerId: provider?.id,
|
||||
providerName: provider?.name || '尚未配置',
|
||||
model: provider?.defaultModel || '',
|
||||
modelName: model?.name || provider?.defaultModel || '尚未选择模型',
|
||||
configured: Boolean(
|
||||
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
|
||||
),
|
||||
status: provider?.status || 'untested',
|
||||
timeoutMs: provider?.advanced.timeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
save(input: AIProviderConfig): AIProviderListResult {
|
||||
const validationError = validateProvider(input)
|
||||
if (validationError) return { success: false, providers: [], error: validationError }
|
||||
const data = this.readMetadata()
|
||||
const existing = data.providers.find((provider) => provider.id === input.id)
|
||||
if (input.apiKey?.trim()) {
|
||||
const saved = this.keyStore.save(input.id, input.apiKey.trim())
|
||||
if (!saved.success) return { success: false, providers: [], error: saved.error }
|
||||
} else if (needsApiKey(input) && !this.keyStore.get(input.id).key) {
|
||||
return { success: false, providers: [], error: '请填写 API Key' }
|
||||
}
|
||||
|
||||
const metadata: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> = {
|
||||
id: input.id,
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
baseUrl: input.baseUrl.trim().replace(/\/+$/, ''),
|
||||
auth: input.auth,
|
||||
models: input.models,
|
||||
defaultModel: input.defaultModel,
|
||||
advanced: input.advanced,
|
||||
status: existing?.status || 'untested',
|
||||
lastTestedAt: existing?.lastTestedAt,
|
||||
lastError: existing?.lastError
|
||||
}
|
||||
const index = data.providers.findIndex((provider) => provider.id === input.id)
|
||||
if (index >= 0) data.providers[index] = metadata
|
||||
else data.providers.push(metadata)
|
||||
if (!data.defaultProviderId) data.defaultProviderId = input.id
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
delete(providerId: string): AIProviderListResult {
|
||||
const data = this.readMetadata()
|
||||
data.providers = data.providers.filter((provider) => provider.id !== providerId)
|
||||
if (data.defaultProviderId === providerId) data.defaultProviderId = data.providers[0]?.id
|
||||
const cleared = this.keyStore.clear(providerId)
|
||||
if (!cleared.success) return { success: false, providers: [], error: cleared.error }
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
setDefault(providerId: string): AIProviderListResult {
|
||||
const data = this.readMetadata()
|
||||
if (!data.providers.some((provider) => provider.id === providerId)) {
|
||||
return { success: false, providers: [], error: '供应商不存在' }
|
||||
}
|
||||
data.defaultProviderId = providerId
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
migrateLegacy(config: LegacyAIConfig): AIProviderListResult {
|
||||
const data = this.readMetadata()
|
||||
if (data.providers.length) return this.list()
|
||||
const provider = deepSeekProvider(config.baseUrl, config.model)
|
||||
if (config.apiKey?.trim()) {
|
||||
const saved = this.keyStore.save(provider.id, config.apiKey.trim())
|
||||
if (!saved.success) return { success: false, providers: [], error: saved.error }
|
||||
}
|
||||
data.providers = [stripRuntimeFields(provider)]
|
||||
data.defaultProviderId = provider.id
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
async test(providerId: string): Promise<AIConnectionTestResult> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
await this.request([{ role: 'user', content: 'Reply with OK.' }], { providerId }, true)
|
||||
this.updateTestStatus(providerId, 'connected')
|
||||
return { success: true, latencyMs: Date.now() - startedAt }
|
||||
} catch (error) {
|
||||
const message = safeAIError(error)
|
||||
this.updateTestStatus(providerId, 'error', message)
|
||||
return { success: false, error: message, latencyMs: Date.now() - startedAt }
|
||||
}
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: AIChatRequestOptions
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
return { success: true, ...(await this.request(messages, options)) }
|
||||
} catch (error) {
|
||||
return { success: false, error: safeAIError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态图片理解。
|
||||
* 输入: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)
|
||||
return { success: true, ...result }
|
||||
} catch (error) {
|
||||
return { success: false, error: safeAIError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
|
||||
const startedAt = Date.now()
|
||||
const imageError = validateVisionImage(request.imageDataUrl)
|
||||
if (imageError) return { success: false, code: 'INVALID_IMAGE', error: imageError }
|
||||
if (!request.prompt.trim()) {
|
||||
return { success: false, code: 'INVALID_IMAGE', error: '请填写图片识别提示词' }
|
||||
}
|
||||
try {
|
||||
const resolved = this.resolveProvider(request)
|
||||
const result = await requestProvider(resolved.provider, resolved.key, resolved.model, [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: request.prompt.trim() },
|
||||
{ type: 'image', dataUrl: request.imageDataUrl }
|
||||
]
|
||||
}
|
||||
])
|
||||
if (!result.data.trim()) throw new Error('API 未返回识别内容')
|
||||
this.markVisionCapability(resolved.provider.id, resolved.model)
|
||||
const model = resolved.provider.models.find((item) => item.id === resolved.model)
|
||||
return {
|
||||
success: true,
|
||||
providerName: resolved.provider.name,
|
||||
modelId: resolved.model,
|
||||
modelName: model?.name || resolved.model,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
usage: result.usage,
|
||||
answer: result.data
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = visionFailure(error)
|
||||
return { success: false, ...failure, latencyMs: Date.now() - startedAt }
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
messages: AIMessage[],
|
||||
options?: AIChatRequestOptions,
|
||||
testing = false
|
||||
): Promise<{
|
||||
data: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
}> {
|
||||
if (options?.apiKey) return this.requestLegacy(messages, options)
|
||||
const resolved = this.resolveProvider(options)
|
||||
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 }): {
|
||||
provider: AIProviderSummary
|
||||
model: string
|
||||
key: string
|
||||
} {
|
||||
const list = this.list()
|
||||
const provider =
|
||||
list.providers.find((item) => item.id === options?.providerId) ||
|
||||
list.providers.find((item) => item.id === list.defaultProviderId)
|
||||
if (!provider) throw new Error('尚未配置 AI Provider')
|
||||
const model = options?.modelId || provider.defaultModel
|
||||
if (!provider.models.some((item) => item.id === model)) throw new Error('当前模型不存在')
|
||||
const key = this.keyStore.get(provider.id).key || ''
|
||||
if (needsApiKey(provider) && !key) throw new Error('当前供应商尚未配置 API Key')
|
||||
return { provider, model, key }
|
||||
}
|
||||
|
||||
private async requestLegacy(
|
||||
messages: AIMessage[],
|
||||
options: AIChatRequestOptions
|
||||
): Promise<AIRequestResult> {
|
||||
const provider = deepSeekProvider(options.baseURL, options.model)
|
||||
return requestOpenAICompatible(
|
||||
provider,
|
||||
options.apiKey || '',
|
||||
options.model || provider.defaultModel,
|
||||
messages
|
||||
)
|
||||
}
|
||||
|
||||
private updateTestStatus(
|
||||
providerId: string,
|
||||
status: 'connected' | 'error',
|
||||
lastError?: string
|
||||
): void {
|
||||
const data = this.readMetadata()
|
||||
const provider = data.providers.find((item) => item.id === providerId)
|
||||
if (!provider) return
|
||||
provider.status = status
|
||||
provider.lastTestedAt = Date.now()
|
||||
provider.lastError = lastError
|
||||
this.writeMetadata(data)
|
||||
}
|
||||
|
||||
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 provider = data.providers.find((item) => item.id === providerId)
|
||||
const model = provider?.models.find((item) => item.id === modelId)
|
||||
if (!provider || !model) return
|
||||
// 老配置可能没有 ocr 字段,补默认 false
|
||||
if (typeof model.capabilities.ocr !== 'boolean') model.capabilities.ocr = false
|
||||
let changed = false
|
||||
if (caps.vision === true && !model.capabilities.vision) {
|
||||
model.capabilities.vision = true
|
||||
// vision 开启默认带 ocr(派生能力)
|
||||
if (!model.capabilities.ocr) {
|
||||
model.capabilities.ocr = true
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (caps.ocr === true && !model.capabilities.ocr) {
|
||||
model.capabilities.ocr = true
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.writeMetadata(data)
|
||||
}
|
||||
|
||||
private ensureEnvironmentMigration(): void {
|
||||
const data = this.readMetadata()
|
||||
if (data.providers.length) return
|
||||
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim()
|
||||
if (!apiKey) return
|
||||
this.migrateLegacy({
|
||||
apiKey,
|
||||
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
|
||||
model: String(import.meta.env.VITE_AI_MODEL || '')
|
||||
})
|
||||
}
|
||||
|
||||
private toSummary(
|
||||
provider: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
|
||||
defaultProviderId?: string
|
||||
): AIProviderSummary {
|
||||
return {
|
||||
...provider,
|
||||
hasApiKey: Boolean(this.keyStore.get(provider.id).key),
|
||||
isDefault: provider.id === defaultProviderId
|
||||
}
|
||||
}
|
||||
|
||||
private readMetadata(): AIProviderMetadataFile {
|
||||
const filePath = this.metadataPath
|
||||
if (!fs.existsSync(filePath)) return { version: 1, providers: [] }
|
||||
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
|
||||
if (data.version !== 1 || !Array.isArray(data.providers))
|
||||
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
|
||||
}
|
||||
|
||||
private writeMetadata(data: AIProviderMetadataFile): void {
|
||||
fs.ensureDirSync(path.dirname(this.metadataPath))
|
||||
fs.writeJsonSync(this.metadataPath, data, { spaces: 2 })
|
||||
}
|
||||
|
||||
private get metadataPath(): string {
|
||||
return path.join(app.getPath('userData'), 'ai-providers.json')
|
||||
}
|
||||
}
|
||||
|
||||
function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
|
||||
const modelId = model?.trim() || 'deepseek-chat'
|
||||
return {
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
type: 'openai-compatible',
|
||||
baseUrl: baseUrl?.trim() || 'https://api.deepseek.com',
|
||||
auth: { type: 'bearer' },
|
||||
models: [
|
||||
{
|
||||
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
|
||||
id: modelId,
|
||||
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: modelId,
|
||||
advanced: { timeoutMs: 120_000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} },
|
||||
hasApiKey: false,
|
||||
isDefault: true,
|
||||
status: 'untested'
|
||||
}
|
||||
}
|
||||
|
||||
function stripRuntimeFields(
|
||||
provider: AIProviderSummary
|
||||
): Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> {
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
baseUrl: provider.baseUrl,
|
||||
auth: provider.auth,
|
||||
models: provider.models,
|
||||
defaultModel: provider.defaultModel,
|
||||
advanced: provider.advanced,
|
||||
status: provider.status,
|
||||
lastTestedAt: provider.lastTestedAt,
|
||||
lastError: provider.lastError
|
||||
}
|
||||
}
|
||||
|
||||
function needsApiKey(provider: Pick<AIProviderConfig, 'type' | 'auth'>): boolean {
|
||||
return provider.type !== 'ollama' && provider.auth.type !== 'none'
|
||||
}
|
||||
|
||||
function validateProvider(provider: AIProviderConfig): string | undefined {
|
||||
if (!provider.id.trim() || !/^[a-z0-9][a-z0-9-_]*$/i.test(provider.id))
|
||||
return '供应商 ID 格式不正确'
|
||||
if (!provider.name.trim()) return '供应商名称不能为空'
|
||||
if (!provider.baseUrl.trim()) return 'Base URL 不能为空'
|
||||
if (!provider.models.length) return '请至少添加一个模型'
|
||||
if (provider.models.some((model) => !model.name.trim() || !model.id.trim()))
|
||||
return '模型名称和 ID 不能为空'
|
||||
if (!provider.models.some((model) => model.id === provider.defaultModel))
|
||||
return '默认模型不在模型列表中'
|
||||
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
|
||||
return '请填写自定义认证字段'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...provider.advanced.extraHeaders
|
||||
}
|
||||
if (!apiKey || provider.auth.type === 'none') return headers
|
||||
if (provider.auth.type === 'bearer') headers.authorization = `Bearer ${apiKey}`
|
||||
else if (provider.auth.type === 'x-api-key') headers['x-api-key'] = apiKey
|
||||
else headers[provider.auth.headerName || 'authorization'] = apiKey
|
||||
return headers
|
||||
}
|
||||
|
||||
function requestProvider(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false
|
||||
): Promise<AIRequestResult> {
|
||||
return provider.type === 'anthropic-messages'
|
||||
? requestAnthropic(provider, apiKey, model, messages, testing)
|
||||
: requestOpenAICompatible(provider, apiKey, model, messages, testing)
|
||||
}
|
||||
|
||||
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
||||
return messages.map((message) => ({
|
||||
role: message.role,
|
||||
content:
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: message.content.map((part) =>
|
||||
part.type === 'text'
|
||||
? { type: 'text', text: part.text }
|
||||
: { type: 'image_url', image_url: { url: part.dataUrl } }
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
||||
return messages
|
||||
.filter((message) => message.role !== 'system')
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content:
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: message.content.map((part) => {
|
||||
if (part.type === 'text') return { type: 'text', text: part.text }
|
||||
const image = parseVisionImage(part.dataUrl)
|
||||
return {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: image.mimeType, data: image.base64 }
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
async function requestOpenAICompatible(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false
|
||||
): Promise<AIRequestResult> {
|
||||
const endpoint = provider.baseUrl.endsWith('/chat/completions')
|
||||
? provider.baseUrl
|
||||
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
||||
const response = await fetchWithTimeout(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildHeaders(provider, apiKey),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: toOpenAIMessages(messages),
|
||||
temperature: provider.advanced.temperature,
|
||||
max_tokens: testing ? 8 : provider.advanced.maxTokens
|
||||
})
|
||||
},
|
||||
provider.advanced.timeoutMs
|
||||
)
|
||||
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
||||
return {
|
||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||
usage: payload.usage
|
||||
? {
|
||||
input: payload.usage.prompt_tokens,
|
||||
output: payload.usage.completion_tokens,
|
||||
total: payload.usage.total_tokens,
|
||||
estimated: false
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAnthropic(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false
|
||||
): Promise<AIRequestResult> {
|
||||
const system = messages
|
||||
.filter((message) => message.role === 'system')
|
||||
.map((message) =>
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.join('\n')
|
||||
)
|
||||
.join('\n\n')
|
||||
const anthropicMessages = toAnthropicMessages(messages)
|
||||
const headers = buildHeaders(provider, apiKey)
|
||||
if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01'
|
||||
const endpoint = provider.baseUrl.endsWith('/messages')
|
||||
? provider.baseUrl
|
||||
: `${provider.baseUrl.replace(/\/+$/, '')}/messages`
|
||||
const response = await fetchWithTimeout(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
system: system || undefined,
|
||||
messages: anthropicMessages,
|
||||
temperature: provider.advanced.temperature,
|
||||
max_tokens: testing ? 8 : provider.advanced.maxTokens || 4096
|
||||
})
|
||||
},
|
||||
provider.advanced.timeoutMs
|
||||
)
|
||||
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||
if (!response.ok)
|
||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
||||
return {
|
||||
data: Array.isArray(payload.content)
|
||||
? payload.content
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text || '')
|
||||
.join('\n')
|
||||
: '',
|
||||
usage: payload.usage
|
||||
? {
|
||||
input: payload.usage.input_tokens,
|
||||
output: payload.usage.output_tokens,
|
||||
total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
|
||||
estimated: false
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs || 120_000))
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal })
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
|
||||
}
|
||||
|
||||
function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } {
|
||||
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
|
||||
if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP')
|
||||
const bytes = Buffer.byteLength(match[2], 'base64')
|
||||
return { mimeType: match[1].toLowerCase(), base64: match[2], bytes }
|
||||
}
|
||||
|
||||
function validateVisionImage(dataUrl: string): string | undefined {
|
||||
try {
|
||||
const image = parseVisionImage(dataUrl)
|
||||
if (!image.bytes) return '图片内容为空'
|
||||
if (image.bytes > 10 * 1024 * 1024) return '图片不能超过 10 MB'
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : '图片无法读取'
|
||||
}
|
||||
}
|
||||
|
||||
function visionFailure(error: unknown): {
|
||||
code: 'VISION_UNSUPPORTED' | 'API_ERROR'
|
||||
error: string
|
||||
} {
|
||||
const message = safeAIError(error)
|
||||
const unsupported =
|
||||
/vision|multimodal|image[_ ]url|image input|image.*support|support.*image|图片.*不支持|不支持.*图片/i.test(
|
||||
message
|
||||
)
|
||||
return unsupported
|
||||
? { code: 'VISION_UNSUPPORTED', error: '当前模型不支持图片理解' }
|
||||
: { code: 'API_ERROR', error: message || 'API 返回错误' }
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { app } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { Contact, Message } from '../../shared/types'
|
||||
|
||||
export interface CachedSelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
interface CachedMessageBucket {
|
||||
updatedAt: number
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
items: Message[]
|
||||
}
|
||||
|
||||
interface BootstrapCacheFile {
|
||||
version: 1
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
updatedAt: number
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
messages?: Record<string, CachedMessageBucket>
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_MESSAGE_BUCKETS = 24
|
||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
||||
|
||||
function normalizeRoot(accountRoot?: string): string {
|
||||
return String(accountRoot || '').trim()
|
||||
}
|
||||
|
||||
function getCacheFile(accountRoot?: string): string {
|
||||
const normalizedRoot = normalizeRoot(accountRoot) || 'default'
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${process.platform}:${normalizedRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
|
||||
}
|
||||
|
||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getCacheFile(normalizedRoot)
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||
return {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||
try {
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeJsonSync(file, cache, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
return (
|
||||
readCacheFile(normalizedRoot) || {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||
}
|
||||
|
||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||
const entries = Object.entries(messages)
|
||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||
entries
|
||||
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
|
||||
.slice(MAX_MESSAGE_BUCKETS)
|
||||
.forEach(([key]) => {
|
||||
delete messages[key]
|
||||
})
|
||||
}
|
||||
|
||||
export function getBootstrapCache(accountRoot?: string): {
|
||||
self?: CachedSelfInfo
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
if (!cache) return null
|
||||
return {
|
||||
self: cache.self,
|
||||
contacts: cache.contacts || [],
|
||||
updatedAt: cache.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
function isRawContactName(contact: Contact): boolean {
|
||||
const name = String(contact.m_nsNickName || '').trim()
|
||||
const username = String(contact.m_nsUsrName || '').trim()
|
||||
if (!name) return true
|
||||
if (name === username) return true
|
||||
if (name.endsWith('@chatroom')) return true
|
||||
if (name.startsWith('Group_') || name.startsWith('Unknown_')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
if (!cache?.contacts?.length) return contacts
|
||||
const avatarByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
||||
return contacts.map((contact) => ({
|
||||
...contact,
|
||||
avatar:
|
||||
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
|
||||
? contact.avatar
|
||||
: avatarByUsername.get(contact.m_nsUsrName),
|
||||
m_nsNickName:
|
||||
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
|
||||
? contact.m_nsNickName
|
||||
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
|
||||
}))
|
||||
}
|
||||
|
||||
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
cache.self = self
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
const avatarByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
cache.contacts = contacts.map((contact) => ({
|
||||
...contact,
|
||||
avatar:
|
||||
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
|
||||
? contact.avatar
|
||||
: avatarByUsername.get(contact.m_nsUsrName),
|
||||
m_nsNickName:
|
||||
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
|
||||
? contact.m_nsNickName
|
||||
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
|
||||
}))
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache || !cache.contacts?.length) return
|
||||
let changed = false
|
||||
cache.contacts = cache.contacts.map((contact) => {
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
if (!avatar || contact.avatar === avatar) return contact
|
||||
changed = true
|
||||
return { ...contact, avatar }
|
||||
})
|
||||
if (!changed) return
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function getCachedMessages(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Message[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)]
|
||||
return bucket?.items || []
|
||||
}
|
||||
|
||||
export function saveCachedMessages(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime: number | undefined,
|
||||
endTime: number | undefined,
|
||||
messages: Message[]
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
const nextMessages = cache.messages || {}
|
||||
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = {
|
||||
updatedAt: Date.now(),
|
||||
startTime,
|
||||
endTime,
|
||||
items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
}
|
||||
pruneMessageBuckets(nextMessages)
|
||||
cache.messages = nextMessages
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||
import {
|
||||
parseImageDatNameFromRow,
|
||||
parseMessageContent,
|
||||
parseStickerMessageFromRow
|
||||
} from '../message-parser'
|
||||
import type {
|
||||
DatabaseKeyValidationCode,
|
||||
DatabaseKeyValidationResult
|
||||
} from '../../shared/database-key'
|
||||
|
||||
export function getCurrentKey(): string {
|
||||
if (!dbRef) return ''
|
||||
try {
|
||||
return dbRef.getWcdb4Client().getKey()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentAccountRoot(): string {
|
||||
if (!dbRef) return ''
|
||||
try {
|
||||
return dbRef.getWcdb4Client().getAccountRoot()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export interface FormattedContact {
|
||||
m_nsUsrName: string
|
||||
m_nsNickName: string
|
||||
md5: string
|
||||
type: 'user' | 'group'
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface FormattedMessage {
|
||||
id: string
|
||||
from: string
|
||||
type: string
|
||||
datetime: string
|
||||
content: string
|
||||
isSender: boolean
|
||||
img?: string
|
||||
name?: string
|
||||
senderId?: string
|
||||
contentData?: ReturnType<typeof parseMessageContent>
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
localId?: number
|
||||
createTime?: number
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export interface GroupSnapshot {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const MSG_TYPE_DICT: Record<number, string> = {
|
||||
1: '普通文本',
|
||||
3: '图片',
|
||||
34: '语音',
|
||||
42: '名片',
|
||||
43: '视频',
|
||||
47: '表情包',
|
||||
48: '位置',
|
||||
49: '分享消息',
|
||||
50: '通话',
|
||||
10000: '系统消息'
|
||||
}
|
||||
|
||||
function normalizeMsgType(value: string | number | undefined): number {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return 0
|
||||
|
||||
try {
|
||||
const parsed = BigInt(raw)
|
||||
const low32 = Number(parsed & 0xffffffffn)
|
||||
return low32 || Number(parsed)
|
||||
} catch {
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return parsed > 0xffffffff ? parsed >>> 0 : parsed
|
||||
}
|
||||
}
|
||||
|
||||
let dbRef: WechatDb | null = null
|
||||
|
||||
export function setChatDb(db: WechatDb | null): void {
|
||||
dbRef?.close()
|
||||
dbRef = db
|
||||
}
|
||||
|
||||
export function getChatDb(): WechatDb | null {
|
||||
return dbRef
|
||||
}
|
||||
|
||||
export function isReady(): boolean {
|
||||
return dbRef !== null
|
||||
}
|
||||
|
||||
export function listContacts(filter?: string): FormattedContact[] {
|
||||
if (!dbRef) return []
|
||||
|
||||
const contacts: FormattedContact[] = []
|
||||
const groupContacts = dbRef.getAllGroupContacts()
|
||||
const userList = dbRef.getUserList(filter)
|
||||
const existingMd5s = new Set<string>()
|
||||
|
||||
for (const user of userList) {
|
||||
const md5 = dbRef.md5(user.m_nsUsrName)
|
||||
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
|
||||
existingMd5s.add(md5)
|
||||
contacts.push({
|
||||
m_nsUsrName: user.m_nsUsrName,
|
||||
m_nsNickName: user.nickname || '未知用户',
|
||||
md5,
|
||||
type: isGroup ? 'group' : 'user',
|
||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
|
||||
})
|
||||
}
|
||||
|
||||
const chatTables = dbRef.getAllChatTables()
|
||||
for (const table of chatTables) {
|
||||
if (!table.name.startsWith('Chat_')) continue
|
||||
const md5 = table.name.substring(5)
|
||||
if (existingMd5s.has(md5)) continue
|
||||
if (groupContacts[md5]) {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Group_${md5}`,
|
||||
m_nsNickName: groupContacts[md5],
|
||||
md5,
|
||||
type: 'group'
|
||||
})
|
||||
} else {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Unknown_${md5}`,
|
||||
m_nsNickName: `Chat_${md5}`,
|
||||
md5,
|
||||
type: 'user'
|
||||
})
|
||||
}
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
export function getContactAvatars(usernames: string[]): Record<string, string> {
|
||||
if (!dbRef) return {}
|
||||
const normalized = Array.from(
|
||||
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (normalized.length === 0) return {}
|
||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||
}
|
||||
|
||||
export function listMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
): FormattedMessage[] {
|
||||
if (!dbRef) return []
|
||||
|
||||
const startedAt = Date.now()
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
const username = wcdb4Client.getUsernameByMd5(userMd5)
|
||||
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
|
||||
console.log(
|
||||
`[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)
|
||||
console.log(
|
||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
|
||||
const formatted = rawMessages.map((msg: WechatMessage) => {
|
||||
const rawMsgType = parseInt(msg.messageType)
|
||||
const msgType = normalizeMsgType(msg.messageType)
|
||||
const createTime = parseInt(msg.msgCreateTime)
|
||||
const date = new Date(createTime * 1000)
|
||||
const isMine = msg.mesDes !== 1
|
||||
const localId = parseInt(msg.mesLocalID) || 0
|
||||
|
||||
let content = msg.msgContent
|
||||
let img = ''
|
||||
let name = ''
|
||||
let senderId = typeof msg.sender === 'string' ? msg.sender : ''
|
||||
if (isMine) {
|
||||
name = typeof msg.senderNickname === 'string' ? msg.senderNickname : ''
|
||||
} else {
|
||||
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
|
||||
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
||||
}
|
||||
if (isGroupChat && content && typeof content === 'string') {
|
||||
const colonIndex = content.indexOf(':')
|
||||
if (colonIndex > 0) {
|
||||
const potentialSenderId = content.substring(0, colonIndex).trim()
|
||||
if (/^[a-zA-Z0-9_@.-]{3,64}$/.test(potentialSenderId)) {
|
||||
senderId = senderId || potentialSenderId
|
||||
name = name || potentialSenderId
|
||||
content = content.substring(colonIndex + 1).replace(/^\s+/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMine && !name && senderId) name = senderId
|
||||
|
||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||
const inferredMsgType =
|
||||
typeof content === 'string' &&
|
||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||
? 49
|
||||
: msgType
|
||||
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
try {
|
||||
const parsed =
|
||||
inferredMsgType === 47
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
: parseMessageContent(content, inferredMsgType)
|
||||
if (parsed.type === 'system') {
|
||||
content = parsed.content
|
||||
contentData = parsed
|
||||
} else if (parsed.type !== 'unknown') {
|
||||
content = ''
|
||||
}
|
||||
if (parsed.type === 'image') {
|
||||
const imageDatName = parseImageDatNameFromRow(msg)
|
||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||
} else if (parsed.type !== 'system') {
|
||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
}
|
||||
contentData = parsed
|
||||
}
|
||||
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
||||
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentData && typeof content === 'string' && /^[0-9a-fA-F]{64,}$/.test(content.trim())) {
|
||||
const parsed = parseStickerMessageFromRow(msg, content)
|
||||
if (parsed.type === 'sticker') {
|
||||
if (!parsed.url && parsed.md5) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
}
|
||||
content = ''
|
||||
contentData = parsed
|
||||
displayType = '表情包'
|
||||
}
|
||||
}
|
||||
|
||||
if (msgType === 34) content = '[语音消息]'
|
||||
|
||||
return {
|
||||
id: msg.mesLocalID || Math.random().toString(),
|
||||
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||
isSender: isMine,
|
||||
type: displayType,
|
||||
datetime: date.toLocaleString('zh-CN', { hour12: false }),
|
||||
content,
|
||||
img,
|
||||
name,
|
||||
senderId,
|
||||
sessionId: username,
|
||||
localId,
|
||||
createTime,
|
||||
contentData
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[ChatService] listMessages end md5=${userMd5} formatted=${formatted.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return formatted
|
||||
}
|
||||
|
||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
if (!dbRef) return null
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||
|
||||
const members = wcdb4Client
|
||||
.getGroupMembers(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 {
|
||||
if (!dbRef) return null
|
||||
return dbRef.searchAllMessages(keyword)
|
||||
}
|
||||
|
||||
export function listRecentChat(limit = 50): FormattedContact[] {
|
||||
const contacts = listContacts()
|
||||
return contacts.slice(0, limit)
|
||||
}
|
||||
|
||||
export function resolveMd5(query: string): FormattedContact | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return null
|
||||
const lower = trimmed.toLowerCase()
|
||||
const contacts = listContacts()
|
||||
|
||||
const exact = contacts.find(
|
||||
(c) =>
|
||||
c.md5 === trimmed ||
|
||||
c.m_nsUsrName.toLowerCase() === lower ||
|
||||
c.m_nsNickName.toLowerCase() === lower
|
||||
)
|
||||
if (exact) return exact
|
||||
|
||||
const partial = contacts.find(
|
||||
(c) =>
|
||||
c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
)
|
||||
return partial || null
|
||||
}
|
||||
|
||||
export interface SelfAccountInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
export function getSelfAccountInfo(): SelfAccountInfo | null {
|
||||
if (!dbRef) return null
|
||||
const wcdb = dbRef.getWcdb4Client()
|
||||
const accountRoot = wcdb.getAccountRoot()
|
||||
const usernameCandidates = wcdb.getMyUsernameCandidates()
|
||||
const primaryUsername = usernameCandidates[0] ?? ''
|
||||
const wxid =
|
||||
primaryUsername && primaryUsername.toLowerCase().startsWith('wxid_')
|
||||
? primaryUsername
|
||||
: wcdb.getUsernameByMd5(wcdb.md5(accountRoot.split('/').pop() || '')) || primaryUsername
|
||||
|
||||
let nickname = ''
|
||||
let avatar: string | undefined
|
||||
try {
|
||||
avatar = wcdb.getMyAvatarUrl()
|
||||
} catch {
|
||||
avatar = undefined
|
||||
}
|
||||
|
||||
if (usernameCandidates.length) {
|
||||
const contacts = listContacts()
|
||||
const self = contacts.find(
|
||||
(c) =>
|
||||
usernameCandidates.includes(c.m_nsUsrName) ||
|
||||
(c.type === 'user' && usernameCandidates.some((u) => c.m_nsUsrName.includes(u)))
|
||||
)
|
||||
if (self) {
|
||||
nickname = self.m_nsNickName
|
||||
avatar = avatar || self.avatar
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wxid: wxid || primaryUsername || '',
|
||||
nickname: nickname || wxid || '我',
|
||||
avatar,
|
||||
accountRoot
|
||||
}
|
||||
}
|
||||
|
||||
export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult {
|
||||
const probeKey = key.replace(/^0x/i, '').trim()
|
||||
if (!/^[0-9a-f]{64}$/i.test(probeKey)) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'INVALID_FORMAT',
|
||||
error: '密钥格式不正确'
|
||||
}
|
||||
}
|
||||
|
||||
let probe: WechatDb | null = null
|
||||
let ownsProbe = false
|
||||
try {
|
||||
const current = dbRef
|
||||
const canReuseCurrent =
|
||||
current &&
|
||||
getCurrentKey().replace(/^0x/i, '').trim() === probeKey &&
|
||||
(!accountRoot || getCurrentAccountRoot() === accountRoot)
|
||||
const validationDb = canReuseCurrent
|
||||
? current
|
||||
: accountRoot
|
||||
? new WechatDb(probeKey, accountRoot)
|
||||
: new WechatDb(probeKey)
|
||||
probe = validationDb
|
||||
ownsProbe = validationDb !== current
|
||||
const client = validationDb.getWcdb4Client()
|
||||
const contacts = client.getSessions()
|
||||
const messages = client.getChatTables()
|
||||
if (contacts[0]) client.getMessages(contacts[0].username, undefined, undefined, { limit: 1 })
|
||||
return {
|
||||
success: true,
|
||||
accountRoot: client.getAccountRoot(),
|
||||
wxid: (client.getMyUsernameCandidates?.() ?? [])[0] || '',
|
||||
contacts: { available: true, count: contacts.length },
|
||||
messages: { available: true, count: messages.length }
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
const code = mapConnectionError(detail)
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: DATABASE_KEY_ERROR_MESSAGES[code]
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// macOS native shutdown is process-wide. Do not tear down the active reader
|
||||
// when validating a replacement key; the new runtime connection takes over on save.
|
||||
if (ownsProbe && !(process.platform === 'darwin' && dbRef)) probe?.close()
|
||||
} catch {
|
||||
// Validation probes are best-effort closed without exposing native details.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DATABASE_KEY_ERROR_MESSAGES: Record<DatabaseKeyValidationCode, string> = {
|
||||
INVALID_FORMAT: '密钥格式不正确',
|
||||
DATABASE_OPEN_FAILED: '无法打开数据库',
|
||||
ACCOUNT_MISMATCH: '密钥与当前账号不匹配',
|
||||
ROOT_UNAVAILABLE: '当前数据库目录不可用',
|
||||
DATABASE_FILE_MISSING: '数据库文件缺失',
|
||||
UNKNOWN_VALIDATION_ERROR: '未知验证错误'
|
||||
}
|
||||
|
||||
function mapConnectionError(detail: string): DatabaseKeyValidationCode {
|
||||
const normalized = detail.toLowerCase()
|
||||
if (normalized.includes('-1005') || normalized.includes('不匹配')) return 'ACCOUNT_MISMATCH'
|
||||
if (normalized.includes('session.db') || normalized.includes('数据库文件')) {
|
||||
return 'DATABASE_FILE_MISSING'
|
||||
}
|
||||
if (
|
||||
normalized.includes('数据目录') ||
|
||||
normalized.includes('账号目录') ||
|
||||
normalized.includes('db_storage')
|
||||
) {
|
||||
return 'ROOT_UNAVAILABLE'
|
||||
}
|
||||
if (normalized.includes('wcdb_open_account') || normalized.includes('open')) {
|
||||
return 'DATABASE_OPEN_FAILED'
|
||||
}
|
||||
return 'UNKNOWN_VALIDATION_ERROR'
|
||||
}
|
||||
|
||||
export function reopenWithRoot(accountRoot: string): boolean {
|
||||
if (!dbRef) return false
|
||||
const key = getCurrentKey()
|
||||
if (!key) return false
|
||||
try {
|
||||
const next = new WechatDb(key, accountRoot)
|
||||
setChatDb(next)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[ChatService] reopen with root failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import type {
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
ImageResourceCheck,
|
||||
TestImageDecryptionRequest
|
||||
} from '../../shared/image-decryption'
|
||||
import { ImageDecryptService } from '../image-decrypt-service'
|
||||
import * as chat from './chat-service'
|
||||
import { validateImageKeyRequest } from './image-key-config-service'
|
||||
import { isWechatRunning } from './wechat-process-status'
|
||||
|
||||
export async function inspectImageDecryptionStatus(
|
||||
config: ImageKeyConfigResult
|
||||
): Promise<ImageDecryptionStatus> {
|
||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
|
||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||
const stickerCacheFound =
|
||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
||||
const dbConnected = chat.isReady()
|
||||
|
||||
return {
|
||||
configured: config.configured,
|
||||
saved: config.saved,
|
||||
encryptionAvailable: config.encryptionAvailable,
|
||||
source: config.source,
|
||||
accountId: config.accountId,
|
||||
resourceRoot: accountRoot,
|
||||
updatedAt: config.updatedAt,
|
||||
platform: process.platform,
|
||||
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
|
||||
wechatRunning: await isWechatRunning(),
|
||||
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
|
||||
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
|
||||
resources: {
|
||||
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
|
||||
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
|
||||
thumbnail: pending(imageDirectoryFound),
|
||||
original: pending(imageDirectoryFound),
|
||||
sticker: stickerCacheFound
|
||||
? check(true, '本地缓存可用')
|
||||
: { state: 'unknown', detail: '独立按需解析' },
|
||||
video: { state: 'unavailable', detail: '当前版本未提供视频媒体解析' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function testImageDecryption(
|
||||
request: TestImageDecryptionRequest
|
||||
): ImageDecryptionTestResult {
|
||||
const normalized = validateImageKeyRequest(request)
|
||||
if (!normalized.success) return failure('NOT_CONFIGURED', normalized.error)
|
||||
if (!chat.isReady() || !request.userMd5) {
|
||||
return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录')
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = chat.listMessages(request.userMd5, undefined, undefined, { limit: 300 })
|
||||
const imageMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.contentData?.type === 'image')
|
||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
|
||||
}
|
||||
|
||||
const service = new ImageDecryptService(
|
||||
normalized.xorKey,
|
||||
normalized.aesKey,
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
)
|
||||
const image = imageMessage.contentData
|
||||
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
|
||||
if (!filePath)
|
||||
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
|
||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||
|
||||
const data = service.decryptImageToBase64(filePath)
|
||||
if (!data) {
|
||||
return {
|
||||
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
|
||||
fileFound: true
|
||||
}
|
||||
}
|
||||
const readable = data.startsWith('data:image/')
|
||||
return {
|
||||
success: readable,
|
||||
code: readable ? undefined : 'DECRYPT_FAILED',
|
||||
error: readable ? undefined : '图片解密结果不可读取',
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
} catch {
|
||||
return failure('UNKNOWN', '图片解析测试未通过')
|
||||
}
|
||||
}
|
||||
|
||||
function hasImageDirectory(accountRoot: string): boolean {
|
||||
if (!accountRoot) return false
|
||||
return [
|
||||
path.join(accountRoot, 'msg', 'attach'),
|
||||
path.join(accountRoot, 'FileStorage', 'Image'),
|
||||
path.join(accountRoot, 'FileStorage', 'Image2'),
|
||||
path.join(accountRoot, 'FileStorage', 'MsgImg')
|
||||
].some((candidate) => fs.existsSync(candidate))
|
||||
}
|
||||
|
||||
function canUseCacheRoot(): boolean {
|
||||
try {
|
||||
const cacheRoot = path.join(app.getPath('userData'), 'cache')
|
||||
fs.ensureDirSync(cacheRoot)
|
||||
return fs.statSync(cacheRoot).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function check(available: boolean, detail: string): ImageResourceCheck {
|
||||
return { state: available ? 'available' : 'unavailable', detail }
|
||||
}
|
||||
|
||||
function pending(directoryFound: boolean): ImageResourceCheck {
|
||||
return directoryFound
|
||||
? { state: 'unknown', detail: '通过图片解析测试确认' }
|
||||
: { state: 'unavailable', detail: '图片目录不可用' }
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: NonNullable<ImageDecryptionTestResult['code']>,
|
||||
error: string
|
||||
): ImageDecryptionTestResult {
|
||||
return { success: false, code, error, fileFound: false, decrypted: false, readable: false }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,207 @@
|
||||
import path from 'path'
|
||||
import type { ImageKeyConfigResult, SaveImageKeyRequest } from '../../shared/image-decryption'
|
||||
import { ImageKeyStore } from '../image-key-store'
|
||||
import * as chat from './chat-service'
|
||||
import { loadSettings, saveSettings, type AppSettings } from './settings-store'
|
||||
|
||||
export class ImageKeyConfigService {
|
||||
constructor(private readonly store = new ImageKeyStore()) {}
|
||||
|
||||
getConfig(): ImageKeyConfigResult {
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
const stored = this.store.get(context.accountId)
|
||||
if (!stored.success) {
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
saved: false,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'none',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot,
|
||||
error: stored.error
|
||||
}
|
||||
}
|
||||
if (stored.entry) {
|
||||
return {
|
||||
success: true,
|
||||
configured: true,
|
||||
saved: true,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'secure-storage',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot,
|
||||
xorKey: stored.entry.xorKey,
|
||||
aesKey: stored.entry.aesKey,
|
||||
updatedAt: stored.entry.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.imageAesKey.trim()) {
|
||||
const legacy = this.buildResult({
|
||||
source: 'legacy-settings',
|
||||
xorKey: settings.imageXorKey || '0x40',
|
||||
aesKey: settings.imageAesKey,
|
||||
context,
|
||||
encryptionAvailable: stored.encryptionAvailable
|
||||
})
|
||||
if (!stored.encryptionAvailable) return legacy
|
||||
const migrated = this.save({
|
||||
resourceRoot: context.resourceRoot,
|
||||
xorKey: legacy.xorKey || '0x40',
|
||||
aesKey: legacy.aesKey || ''
|
||||
})
|
||||
return migrated.success ? this.getConfig() : legacy
|
||||
}
|
||||
|
||||
const envAesKey = String(import.meta.env.VITE_IMAGE_AES_KEY || '').trim()
|
||||
if (!settings.imageKeyFallbackDisabled && envAesKey) {
|
||||
return this.buildResult({
|
||||
source: 'environment',
|
||||
xorKey: String(import.meta.env.VITE_IMAGE_XOR_KEY || '0x40'),
|
||||
aesKey: envAesKey,
|
||||
context,
|
||||
encryptionAvailable: stored.encryptionAvailable
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
configured: false,
|
||||
saved: false,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'none',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot
|
||||
}
|
||||
}
|
||||
|
||||
save(request: SaveImageKeyRequest): ImageKeyConfigResult {
|
||||
const normalized = validateImageKeyRequest(request)
|
||||
if (!normalized.success) return { ...this.getEmptyConfig(), error: normalized.error }
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
if (!chat.getSelfAccountInfo()?.wxid) {
|
||||
return { ...this.getEmptyConfig(), error: '当前微信账号尚未识别' }
|
||||
}
|
||||
const result = this.store.save(context.accountId, {
|
||||
xorKey: normalized.xorKey,
|
||||
aesKey: normalized.aesKey
|
||||
})
|
||||
if (!result.success || !result.entry) {
|
||||
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
||||
}
|
||||
saveSettings({
|
||||
...settings,
|
||||
imageKeyRoot: normalized.resourceRoot,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
configured: true,
|
||||
saved: true,
|
||||
encryptionAvailable: true,
|
||||
source: 'secure-storage',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: normalized.resourceRoot,
|
||||
xorKey: result.entry.xorKey,
|
||||
aesKey: result.entry.aesKey,
|
||||
updatedAt: result.entry.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
clear(): { success: boolean; error?: string } {
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
const cleared = this.store.clear(context.accountId)
|
||||
if (!cleared.success) return cleared
|
||||
saveSettings({
|
||||
...settings,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: true
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
getLegacySettingsView(): AppSettings {
|
||||
const settings = loadSettings()
|
||||
const config = this.getConfig()
|
||||
return {
|
||||
...settings,
|
||||
imageXorKey: config.xorKey || '',
|
||||
imageAesKey: config.aesKey || ''
|
||||
}
|
||||
}
|
||||
|
||||
private getContext(settings: AppSettings): { accountId: string; resourceRoot: string } {
|
||||
const self = chat.getSelfAccountInfo()
|
||||
const accountRoot = self?.accountRoot || chat.getCurrentAccountRoot() || settings.dbRoot
|
||||
return {
|
||||
accountId: self?.wxid || path.basename(accountRoot || '') || 'unbound',
|
||||
resourceRoot: settings.imageKeyRoot || accountRoot || settings.dbRoot
|
||||
}
|
||||
}
|
||||
|
||||
private buildResult(input: {
|
||||
source: 'legacy-settings' | 'environment'
|
||||
xorKey: string
|
||||
aesKey: string
|
||||
context: { accountId: string; resourceRoot: string }
|
||||
encryptionAvailable: boolean
|
||||
}): ImageKeyConfigResult {
|
||||
return {
|
||||
success: true,
|
||||
configured: true,
|
||||
saved: false,
|
||||
encryptionAvailable: input.encryptionAvailable,
|
||||
source: input.source,
|
||||
accountId: input.context.accountId,
|
||||
resourceRoot: input.context.resourceRoot,
|
||||
xorKey: normalizeImageXorKey(input.xorKey),
|
||||
aesKey: input.aesKey.trim()
|
||||
}
|
||||
}
|
||||
|
||||
private getEmptyConfig(): ImageKeyConfigResult {
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
const stored = this.store.get(context.accountId)
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
saved: false,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'none',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeImageXorKey(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return '0x40'
|
||||
const parsed = raw.toLowerCase().startsWith('0x')
|
||||
? Number.parseInt(raw.slice(2), 16)
|
||||
: Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 255) return raw
|
||||
return `0x${parsed.toString(16).toUpperCase().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function validateImageKeyRequest(
|
||||
request: SaveImageKeyRequest
|
||||
):
|
||||
| { success: true; resourceRoot: string; xorKey: string; aesKey: string }
|
||||
| { success: false; error: string } {
|
||||
const resourceRoot = request.resourceRoot.trim()
|
||||
const xorKey = normalizeImageXorKey(request.xorKey)
|
||||
const aesKey = request.aesKey.trim()
|
||||
if (!resourceRoot) return { success: false, error: '图片资源目录不能为空' }
|
||||
if (!/^0x[0-9A-F]{2}$/.test(xorKey)) return { success: false, error: 'XOR Key 格式不正确' }
|
||||
if (aesKey.length !== 16) return { success: false, error: 'AES Key 必须为 16 个字符' }
|
||||
return { success: true, resourceRoot, xorKey, aesKey }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import http from 'http'
|
||||
import { apiServer } from '../http-server'
|
||||
import {
|
||||
LOCAL_API_ENDPOINTS,
|
||||
type LocalApiEndpointId,
|
||||
type LocalApiTestRequest,
|
||||
type LocalApiTestResponse
|
||||
} from '../../shared/local-api-test'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
const MAX_BODY_SIZE = 512 * 1024
|
||||
|
||||
function isEndpointId(value: unknown): value is LocalApiEndpointId {
|
||||
return typeof value === 'string' && value in LOCAL_API_ENDPOINTS
|
||||
}
|
||||
|
||||
function requestHost(host: string): string {
|
||||
if (host === '0.0.0.0') return '127.0.0.1'
|
||||
if (host === '::') return '::1'
|
||||
return host
|
||||
}
|
||||
|
||||
function invalidResponse(message: string): LocalApiTestResponse {
|
||||
return {
|
||||
ok: false,
|
||||
method: 'GET',
|
||||
path: '',
|
||||
url: '',
|
||||
durationMs: 0,
|
||||
responseSize: 0,
|
||||
errorCode: 'INVALID_REQUEST',
|
||||
error: message
|
||||
}
|
||||
}
|
||||
|
||||
function parseBody(bodyText: string, contentType?: string): { json?: unknown; bodyText?: string } {
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
return { json: JSON.parse(bodyText) }
|
||||
} catch {
|
||||
// Keep malformed responses readable to the tester.
|
||||
}
|
||||
}
|
||||
return { bodyText }
|
||||
}
|
||||
|
||||
export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTestResponse> {
|
||||
if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效')
|
||||
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
|
||||
if (!isEndpointId(endpointId)) return invalidResponse('不允许访问该 API 端点')
|
||||
if (!query || typeof query !== 'object' || Array.isArray(query))
|
||||
return invalidResponse('查询参数格式无效')
|
||||
if (typeof body !== 'string' || Buffer.byteLength(body) > MAX_BODY_SIZE)
|
||||
return invalidResponse('请求体格式无效')
|
||||
|
||||
const endpoint = LOCAL_API_ENDPOINTS[endpointId]
|
||||
const entries = Object.entries(query)
|
||||
if (
|
||||
entries.some(
|
||||
([key, value]) => !endpoint.queryKeys.includes(key as never) || typeof value !== 'string'
|
||||
)
|
||||
) {
|
||||
return invalidResponse('查询参数不属于当前端点')
|
||||
}
|
||||
|
||||
const service = apiServer.getState()
|
||||
const targetHost = requestHost(service.host)
|
||||
const targetPort = service.port
|
||||
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
|
||||
const url = new URL(endpoint.path, `http://${hostPart}:${targetPort}`)
|
||||
entries.forEach(([key, value]) => {
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
|
||||
if (!service.running) {
|
||||
return {
|
||||
ok: false,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
url: url.toString(),
|
||||
durationMs: 0,
|
||||
responseSize: 0,
|
||||
errorCode: 'API_NOT_RUNNING',
|
||||
error: '请先启动本地 API 服务'
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now()
|
||||
let settled = false
|
||||
const finish = (result: LocalApiTestResponse): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const request = http.request(
|
||||
url,
|
||||
{
|
||||
method: endpoint.method,
|
||||
headers: endpoint.method === 'POST' ? { 'Content-Type': 'application/json' } : undefined
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = []
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
response.on('end', () => {
|
||||
const bodyText = Buffer.concat(chunks).toString('utf-8')
|
||||
const contentType = Array.isArray(response.headers['content-type'])
|
||||
? response.headers['content-type'][0]
|
||||
: response.headers['content-type']
|
||||
finish({
|
||||
ok: true,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
url: url.toString(),
|
||||
status: response.statusCode || 0,
|
||||
statusText: response.statusMessage || '',
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: Buffer.byteLength(bodyText),
|
||||
contentType,
|
||||
...parseBody(bodyText, contentType)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
||||
request.destroy(new Error('请求超时'))
|
||||
finish({
|
||||
ok: false,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
url: url.toString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: 0,
|
||||
errorCode: 'TIMEOUT',
|
||||
error: '请求超时(10 秒)'
|
||||
})
|
||||
})
|
||||
request.on('error', (error: NodeJS.ErrnoException) => {
|
||||
finish({
|
||||
ok: false,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
url: url.toString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: 0,
|
||||
errorCode: error.code === 'ECONNREFUSED' ? 'CONNECTION_REFUSED' : 'UNKNOWN',
|
||||
error: error.message
|
||||
})
|
||||
})
|
||||
if (endpoint.method === 'POST') request.write(body)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
export interface AppSettings {
|
||||
dbRoot: string
|
||||
apiEnabled: boolean
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
imageKeyFallbackDisabled: boolean
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
const home = os.homedir()
|
||||
const candidates = getDefaultDbRootCandidates(home)
|
||||
return candidates.find((candidate) => isUsableDbRoot(candidate)) || candidates[0]
|
||||
}
|
||||
|
||||
function getDefaultDbRootCandidates(home: string): string[] {
|
||||
if (process.platform !== 'win32') {
|
||||
return [
|
||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||
]
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...getWeflowDbPathCandidates(home),
|
||||
path.join(home, 'Documents', 'WeChat Files'),
|
||||
path.join(home, 'Documents', 'xwechat_files'),
|
||||
path.join(home, 'WeChat 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)
|
||||
}
|
||||
|
||||
function getWeflowDbPathCandidates(home: string): string[] {
|
||||
const configPaths = [
|
||||
path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
|
||||
path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
|
||||
]
|
||||
const candidates: string[] = []
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
|
||||
if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
|
||||
candidates.push(config.dbPath.trim())
|
||||
}
|
||||
} catch {
|
||||
// WeFlow is optional; ignore missing or unreadable config.
|
||||
}
|
||||
}
|
||||
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[] {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
|
||||
function isUsableDbRoot(candidate?: string): boolean {
|
||||
if (!candidate || !fs.existsSync(candidate)) return false
|
||||
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(candidate)
|
||||
.some((name) => fs.existsSync(path.join(candidate, name, 'db_storage')))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDbRoot = getDefaultDbRoot()
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
dbRoot: defaultDbRoot,
|
||||
apiEnabled: true,
|
||||
apiHost: '127.0.0.1',
|
||||
apiPort: 6131,
|
||||
imageKeyRoot: defaultDbRoot,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = path.join(
|
||||
process.env['WE_SETTINGS_DIR'] || app.getPath('userData'),
|
||||
'settings.json'
|
||||
)
|
||||
|
||||
let cache: AppSettings | null = null
|
||||
|
||||
function ensureDir(): void {
|
||||
fs.ensureDirSync(path.dirname(SETTINGS_FILE))
|
||||
}
|
||||
|
||||
export function loadSettings(): AppSettings {
|
||||
if (cache) return cache
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
||||
cache.dbRoot = getDefaultDbRoot()
|
||||
}
|
||||
if (!cache.imageKeyRoot) {
|
||||
cache.imageKeyRoot = cache.dbRoot
|
||||
}
|
||||
return cache
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Settings] failed to load, fallback to defaults:', error)
|
||||
}
|
||||
cache = { ...DEFAULT_SETTINGS }
|
||||
return cache
|
||||
}
|
||||
|
||||
export function saveSettings(next: AppSettings): AppSettings {
|
||||
cache = { ...next }
|
||||
try {
|
||||
ensureDir()
|
||||
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.error('[Settings] failed to save:', error)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
export function updateSettings(patch: Partial<AppSettings>): AppSettings {
|
||||
return saveSettings({ ...loadSettings(), ...patch })
|
||||
}
|
||||
|
||||
export function resetSettings(): AppSettings {
|
||||
cache = null
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) fs.unlinkSync(SETTINGS_FILE)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
return loadSettings()
|
||||
}
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
return SETTINGS_FILE
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { existsSync, promises as fs } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||
const GITHUB_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
|
||||
|
||||
export interface SkillResourceStatus {
|
||||
available: boolean
|
||||
version?: string
|
||||
filePath?: string
|
||||
directoryPath?: string
|
||||
source: 'development' | 'bundled'
|
||||
githubUrl: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
function getSkillCandidates(): { path: string; source: 'development' | 'bundled' }[] {
|
||||
const developmentPath = join(app.getAppPath(), 'docs', SKILL_RELATIVE_PATH)
|
||||
const bundledPaths = [
|
||||
join(process.resourcesPath, SKILL_RELATIVE_PATH),
|
||||
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
|
||||
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
|
||||
]
|
||||
return app.isPackaged
|
||||
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
|
||||
: [
|
||||
{ path: developmentPath, source: 'development' as const },
|
||||
...bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
|
||||
]
|
||||
}
|
||||
|
||||
function getStatus(): SkillResourceStatus {
|
||||
const candidates = getSkillCandidates()
|
||||
const resolved = candidates.find((candidate) => existsSync(candidate.path))
|
||||
const filePath = resolved?.path || candidates[0].path
|
||||
const source = resolved?.source || candidates[0].source
|
||||
const directoryPath = dirname(filePath)
|
||||
if (!resolved) {
|
||||
return {
|
||||
available: false,
|
||||
source,
|
||||
githubUrl: GITHUB_URL,
|
||||
error: `未找到 WechatExplorer Reader Skill 文件(已检查:${candidates.map((item) => item.path).join(';')})`
|
||||
}
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
version: 'v1.0',
|
||||
filePath,
|
||||
directoryPath,
|
||||
source,
|
||||
githubUrl: GITHUB_URL
|
||||
}
|
||||
}
|
||||
|
||||
export const skillResourceService = {
|
||||
getStatus,
|
||||
|
||||
async read(): Promise<{ success: boolean; content?: string; error?: string }> {
|
||||
const status = this.getStatus()
|
||||
if (!status.available || !status.filePath) return { success: false, error: status.error }
|
||||
try {
|
||||
return { success: true, content: await fs.readFile(status.filePath, 'utf-8') }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
|
||||
async reveal(): Promise<{ success: boolean; error?: string }> {
|
||||
const status = this.getStatus()
|
||||
if (!status.available || !status.directoryPath) return { success: false, error: status.error }
|
||||
try {
|
||||
const error = await shell.openPath(status.directoryPath)
|
||||
if (error) return { success: false, error }
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
|
||||
async openGithub(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await shell.openExternal(GITHUB_URL)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const WINDOWS_WECHAT_IMAGES = ['Weixin.exe', 'WeChat.exe']
|
||||
|
||||
export async function isWindowsWechatRunning(): Promise<boolean> {
|
||||
if (process.platform !== 'win32') return false
|
||||
|
||||
for (const imageName of WINDOWS_WECHAT_IMAGES) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'tasklist',
|
||||
['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'],
|
||||
{ windowsHide: true }
|
||||
)
|
||||
if (stdout.toLowerCase().includes(`"${imageName.toLowerCase()}"`)) return true
|
||||
} catch {
|
||||
// Try the other supported WeChat executable name.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function isWechatRunning(): Promise<boolean> {
|
||||
if (process.platform === 'win32') return isWindowsWechatRunning()
|
||||
if (process.platform !== 'darwin') return false
|
||||
|
||||
for (const args of [
|
||||
['-x', 'WeChat'],
|
||||
['-f', 'WeChat.app/Contents/MacOS/WeChat']
|
||||
]) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/pgrep', args)
|
||||
if (stdout.trim()) return true
|
||||
} catch {
|
||||
// Try the next macOS process lookup strategy.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs-extra'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
type StickerResult = { success: boolean; data?: string; error?: string }
|
||||
|
||||
const downloadCache = new Map<string, Promise<StickerResult>>()
|
||||
|
||||
export class StickerService {
|
||||
private readonly cacheDir: string
|
||||
|
||||
constructor(private readonly wcdb4Client?: Wcdb4Client | null) {
|
||||
this.cacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
||||
}
|
||||
|
||||
async resolveSticker(cdnUrl?: string, md5?: string): Promise<StickerResult> {
|
||||
const normalizedMd5 = this.normalizeMd5(md5)
|
||||
let url = String(cdnUrl || '').trim()
|
||||
|
||||
if (!url && normalizedMd5 && this.wcdb4Client) {
|
||||
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
|
||||
if (!url) {
|
||||
console.warn(`[StickerService] emoticon CDN URL not found for md5=${normalizedMd5}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return { success: false, error: '未找到表情包 CDN URL' }
|
||||
}
|
||||
|
||||
const cacheKey = normalizedMd5 || 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 wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
|
||||
if (wechatCached) return { success: true, data: wechatCached }
|
||||
}
|
||||
|
||||
const pending = downloadCache.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = this.downloadToDataUrl(url, cacheKey)
|
||||
downloadCache.set(cacheKey, task)
|
||||
try {
|
||||
return await task
|
||||
} finally {
|
||||
downloadCache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async readCached(cacheKey: string): Promise<string | null> {
|
||||
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||
const cacheDirs = [this.cacheDir, path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')]
|
||||
for (const cacheDir of cacheDirs) {
|
||||
for (const ext of extensions) {
|
||||
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
const buffer = await fs.readFile(filePath)
|
||||
return this.toDataUrl(buffer, ext)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async readWechatEmoticonCache(md5: string): Promise<string | null> {
|
||||
const accountRoot = this.wcdb4Client?.getAccountRoot()
|
||||
if (!accountRoot) return null
|
||||
|
||||
const cacheRoot = path.join(accountRoot, 'cache')
|
||||
if (!fs.existsSync(cacheRoot)) return null
|
||||
|
||||
const prefix = md5.slice(0, 2)
|
||||
let months: string[] = []
|
||||
try {
|
||||
months = fs
|
||||
.readdirSync(cacheRoot)
|
||||
.filter((name) => /^\d{4}-\d{2}$/.test(name))
|
||||
.sort()
|
||||
.reverse()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const month of months) {
|
||||
const filePath = path.join(cacheRoot, month, 'Emoticon', prefix, md5)
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
const buffer = await fs.readFile(filePath)
|
||||
const ext = this.detectExtension(buffer) || '.gif'
|
||||
return this.toDataUrl(buffer, ext)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private downloadToDataUrl(
|
||||
url: string,
|
||||
cacheKey: string,
|
||||
redirectCount = 0
|
||||
): Promise<StickerResult> {
|
||||
return new Promise((resolve) => {
|
||||
if (redirectCount > 5) {
|
||||
resolve({ success: false, error: '表情包下载重定向过多' })
|
||||
return
|
||||
}
|
||||
|
||||
const client = url.startsWith('https:') ? https : http
|
||||
const request = client.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 MicroMessenger WechatExplorer',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
}
|
||||
},
|
||||
(response) => {
|
||||
const redirectUrl = response.headers.location
|
||||
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
|
||||
const nextUrl = new URL(redirectUrl, url).toString()
|
||||
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
console.warn(
|
||||
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
|
||||
)
|
||||
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
|
||||
return
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
response.on('end', async () => {
|
||||
const buffer = Buffer.concat(chunks)
|
||||
if (buffer.length === 0) {
|
||||
resolve({ success: false, error: '表情包内容为空' })
|
||||
return
|
||||
}
|
||||
|
||||
const ext = this.detectExtension(buffer) || this.getExtFromUrl(url) || '.gif'
|
||||
try {
|
||||
await fs.ensureDir(this.cacheDir)
|
||||
await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer)
|
||||
} catch {
|
||||
// Cache is best effort; the data URL can still be displayed.
|
||||
}
|
||||
resolve({ success: true, data: this.toDataUrl(buffer, ext) })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
request.on('error', (error) => resolve({ success: false, error: error.message }))
|
||||
request.setTimeout(15000, () => {
|
||||
request.destroy()
|
||||
resolve({ success: false, error: '表情包下载超时' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private detectExtension(buffer: Buffer): string | null {
|
||||
if (buffer.length >= 6 && buffer.subarray(0, 3).toString('ascii') === 'GIF') return '.gif'
|
||||
if (
|
||||
buffer.length >= 8 &&
|
||||
buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
)
|
||||
return '.png'
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
|
||||
return '.jpg'
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private getExtFromUrl(url: string): string | null {
|
||||
try {
|
||||
const ext = path.extname(new URL(url).pathname).toLowerCase()
|
||||
return ['.gif', '.png', '.webp', '.jpg', '.jpeg'].includes(ext) ? ext : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private toDataUrl(buffer: Buffer, ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.gif': 'image/gif',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg'
|
||||
}
|
||||
return `data:${mimeTypes[ext] || 'image/gif'};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
|
||||
private normalizeMd5(value?: string): string | undefined {
|
||||
const md5 = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
export class VoiceService {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private voiceCache = new Map<string, string>()
|
||||
|
||||
constructor(wcdb4Client: Wcdb4Client) {
|
||||
this.wcdb4Client = wcdb4Client
|
||||
}
|
||||
|
||||
async resolveVoice(
|
||||
sessionId: string,
|
||||
localId: number,
|
||||
createTime: number,
|
||||
svrId?: string | number
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
const cacheKey = this.buildCacheKey(sessionId, localId, createTime)
|
||||
|
||||
const cached = this.voiceCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log('[VoiceService] cache hit for', cacheKey)
|
||||
return { success: true, data: cached }
|
||||
}
|
||||
|
||||
const candidates = this.buildCandidates(sessionId)
|
||||
console.log('[VoiceService] resolving voice:', { sessionId, localId, createTime, candidates })
|
||||
|
||||
const voiceResult = await this.wcdb4Client.getVoiceData(
|
||||
sessionId,
|
||||
createTime,
|
||||
candidates,
|
||||
localId,
|
||||
svrId || 0
|
||||
)
|
||||
|
||||
if (!voiceResult.success || !voiceResult.hex) {
|
||||
console.log('[VoiceService] getVoiceData failed:', voiceResult.error)
|
||||
return { success: false, error: voiceResult.error || '获取语音数据失败' }
|
||||
}
|
||||
|
||||
console.log('[VoiceService] got hex data, length:', voiceResult.hex.length)
|
||||
|
||||
const silkData = this.decodeVoiceBlob(voiceResult.hex)
|
||||
if (!silkData || silkData.length === 0) {
|
||||
console.log('[VoiceService] decodeVoiceBlob failed, hex:', voiceResult.hex.substring(0, 100))
|
||||
return { success: false, error: '语音数据为空' }
|
||||
}
|
||||
|
||||
console.log('[VoiceService] silkData length:', silkData.length)
|
||||
|
||||
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||
if (!pcmData || pcmData.length === 0) {
|
||||
console.log('[VoiceService] decodeSilkToPcm failed')
|
||||
return { success: false, error: 'Silk 解码失败' }
|
||||
}
|
||||
|
||||
console.log('[VoiceService] pcmData length:', pcmData.length)
|
||||
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
console.log(
|
||||
'[VoiceService] wavData length:',
|
||||
wavData.length,
|
||||
'base64 length:',
|
||||
wavData.toString('base64').length
|
||||
)
|
||||
|
||||
const base64Data = wavData.toString('base64')
|
||||
|
||||
this.voiceCache.set(cacheKey, base64Data)
|
||||
|
||||
return { success: true, data: base64Data }
|
||||
}
|
||||
|
||||
private buildCacheKey(sessionId: string, localId: number, createTime: number): string {
|
||||
return `${sessionId}-${localId}-${createTime}`
|
||||
}
|
||||
|
||||
private buildCandidates(sessionId: string): string[] {
|
||||
const candidates: string[] = [sessionId]
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
candidates.push(sessionId.replace('@chatroom', ''))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private decodeVoiceBlob(hex: string): Buffer | null {
|
||||
try {
|
||||
const hexClean = hex.replace(/\s+/g, '')
|
||||
if (!/^[0-9a-fA-F]+$/.test(hexClean)) {
|
||||
return null
|
||||
}
|
||||
return Buffer.from(hexClean, 'hex')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||
try {
|
||||
let wasmPath: string
|
||||
if (app.isPackaged) {
|
||||
wasmPath = join(
|
||||
process.resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'silk-wasm',
|
||||
'lib',
|
||||
'silk.wasm'
|
||||
)
|
||||
if (!existsSync(wasmPath)) {
|
||||
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
}
|
||||
} else {
|
||||
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
}
|
||||
|
||||
if (!existsSync(wasmPath)) {
|
||||
console.error('[VoiceService] silk.wasm not found at:', wasmPath)
|
||||
return null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const silkWasm = require('silk-wasm')
|
||||
if (!silkWasm || !silkWasm.decode) {
|
||||
console.error('[VoiceService] silk-wasm module invalid')
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await silkWasm.decode(silkData, sampleRate)
|
||||
return Buffer.from(result.data)
|
||||
} catch (e) {
|
||||
console.error('[VoiceService] decodeSilkToPcm error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private createWavBuffer(
|
||||
pcmData: Buffer,
|
||||
sampleRate: number = 24000,
|
||||
channels: number = 1
|
||||
): Buffer {
|
||||
const pcmLength = pcmData.length
|
||||
const header = Buffer.alloc(44)
|
||||
header.write('RIFF', 0)
|
||||
header.writeUInt32LE(36 + pcmLength, 4)
|
||||
header.write('WAVE', 8)
|
||||
header.write('fmt ', 12)
|
||||
header.writeUInt32LE(16, 16)
|
||||
header.writeUInt16LE(1, 20)
|
||||
header.writeUInt16LE(channels, 22)
|
||||
header.writeUInt32LE(sampleRate, 24)
|
||||
header.writeUInt32LE(sampleRate * channels * 2, 28)
|
||||
header.writeUInt16LE(channels * 2, 32)
|
||||
header.writeUInt16LE(16, 34)
|
||||
header.write('data', 36)
|
||||
header.writeUInt32LE(pcmLength, 40)
|
||||
return Buffer.concat([header, pcmData])
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import DatabaseConstructor from 'better-sqlite3-multiple-ciphers'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
|
||||
type Database = import('better-sqlite3-multiple-ciphers').Database
|
||||
import { Wcdb4Client, Wcdb4MessageQueryOptions } from './wcdb4-client'
|
||||
|
||||
export interface UserContact {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface WechatMessage {
|
||||
@@ -26,6 +21,7 @@ export interface Contact {
|
||||
m_nsNickName: string
|
||||
md5: string
|
||||
type: 'user' | 'group'
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface GroupMemberInfo {
|
||||
@@ -35,308 +31,148 @@ export interface GroupMemberInfo {
|
||||
}
|
||||
|
||||
export class WechatDb {
|
||||
private static WECHAT_DIR = path.join(
|
||||
os.homedir(),
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||
)
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private chatMd5ToUsername = new Map<string, string>()
|
||||
|
||||
private rawKey: string
|
||||
private correctUserId: string | null = null
|
||||
private chatDb: { name: string; db_number: string }[] | null = null
|
||||
private groupMemberCache = new Map<string, GroupMemberInfo | null>()
|
||||
|
||||
constructor(rawKey: string) {
|
||||
this.rawKey = rawKey
|
||||
console.log(`Initializing WechatDb with key: ${rawKey}`)
|
||||
|
||||
if (!fs.existsSync(WechatDb.WECHAT_DIR)) {
|
||||
throw new Error(`WeChat directory not found at ${WechatDb.WECHAT_DIR}`)
|
||||
}
|
||||
|
||||
if (this.getUser()) {
|
||||
console.log('User found, getting chat DB number')
|
||||
this.chatDb = this.getChatDbNumber()
|
||||
} else {
|
||||
throw new Error('No valid user found or invalid key')
|
||||
}
|
||||
}
|
||||
|
||||
private connectDb(dbPath: string): Database | null {
|
||||
const targetPath = path.resolve(dbPath)
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
console.error(`❌ 错误:数据库文件不存在于路径:${targetPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new DatabaseConstructor(targetPath, {
|
||||
// verbose: console.log
|
||||
})
|
||||
|
||||
// 配置加密方案 (Cipher Scheme)
|
||||
db.pragma("cipher='sqlcipher'")
|
||||
db.pragma('cipher_page_size = 1024')
|
||||
db.pragma('legacy=3')
|
||||
db.pragma('kdf_iter = 64000')
|
||||
db.pragma('cipher_hmac_algorithm = HMAC_SHA1')
|
||||
db.pragma('cipher_kdf_algorithm = PBKDF2_HMAC_SHA1')
|
||||
|
||||
const processedKey = this.processRawKey(this.rawKey)
|
||||
// console.log(`🔑 应用密钥 (前6位): ${processedKey.substring(0, 6)}...`)
|
||||
|
||||
db.pragma(`key = "x'${processedKey}'"`)
|
||||
|
||||
// 验证连接
|
||||
db.prepare('SELECT count(*) as count FROM sqlite_master').get()
|
||||
|
||||
return db
|
||||
} catch (error) {
|
||||
console.error(`❌ 连接失败:`, error)
|
||||
|
||||
const errString = String(error)
|
||||
if (errString.includes('file is not a database')) {
|
||||
console.error(
|
||||
"💡 诊断建议:\n1. 请确认是否已安装 'better-sqlite3-multiple-ciphers'。\n2. 检查密钥是否正确(WeChat密钥与设备强绑定)。\n3. 尝试将页大小改为 4096 (db.pragma('cipher_page_size = 4096'))。"
|
||||
)
|
||||
} else if (errString.includes('HMAC')) {
|
||||
console.error(
|
||||
'💡 诊断建议:密钥可能正确,但HMAC算法或页大小不匹配。尝试调整 cipher_hmac_algorithm。'
|
||||
)
|
||||
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
||||
// WCDB native init must run on the Electron main process; worker threads
|
||||
// get -1006 from wcdb_init. Initialize the client synchronously here
|
||||
// (and keep create() async for callers that already await it).
|
||||
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)))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private processRawKey(key: string): string {
|
||||
// 移除可能的 '0x' 前缀并修剪空格
|
||||
return key.replace(/^0x/i, '').trim()
|
||||
}
|
||||
|
||||
private getUser(): boolean {
|
||||
const keyValuePath = path.join(WechatDb.WECHAT_DIR, 'KeyValue')
|
||||
if (!fs.existsSync(keyValuePath)) return false
|
||||
|
||||
const contents = fs.readdirSync(keyValuePath)
|
||||
const potentialUsers = contents.filter((name) => !name.startsWith('.'))
|
||||
|
||||
for (const user of potentialUsers) {
|
||||
const dbPath = path.join(keyValuePath, user, 'KeyValue.db')
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const db = this.connectDb(dbPath)
|
||||
if (db) {
|
||||
this.correctUserId = user
|
||||
console.log(`Valid user found: ${user}`)
|
||||
db.close()
|
||||
return true
|
||||
}
|
||||
constructor(
|
||||
rawKey: string,
|
||||
accountRoot?: string,
|
||||
clientOverride?: Wcdb4Client,
|
||||
initialChatTables?: { name: string; db_number: string }[]
|
||||
) {
|
||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||
const client =
|
||||
clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||
if (!clientOverride) client.open()
|
||||
this.wcdb4Client = client
|
||||
for (const table of initialChatTables || client.getChatTables()) {
|
||||
if (table.name.startsWith('Chat_')) {
|
||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private getChatDbNumber(): { name: string; db_number: string }[] {
|
||||
const chatDb: { name: string; db_number: string }[] = []
|
||||
if (!this.correctUserId) return []
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', `msg_${i}.db`)
|
||||
if (!fs.existsSync(dbPath)) continue
|
||||
|
||||
const db = this.connectDb(dbPath)
|
||||
if (db) {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
|
||||
.all() as { name: string }[]
|
||||
for (const table of tables) {
|
||||
chatDb.push({ name: table.name, db_number: `msg_${i}.db` })
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
return chatDb
|
||||
}
|
||||
|
||||
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||
if (!this.correctUserId) return []
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Contact/wccontact_new2.db')
|
||||
const db = this.connectDb(dbPath)
|
||||
if (!db) return []
|
||||
|
||||
let query = "SELECT m_nsUsrName, nickname FROM WCContact WHERE m_nsUsrName NOT LIKE 'gh_%'"
|
||||
if (nicknameFilter) {
|
||||
query += ` AND nickname LIKE '%${nicknameFilter}%'`
|
||||
}
|
||||
|
||||
const results = db.prepare(query).all() as unknown as UserContact[]
|
||||
db.close()
|
||||
return results
|
||||
const keyword = (nicknameFilter || '').trim().toLowerCase()
|
||||
return this.wcdb4Client
|
||||
.getSessions()
|
||||
.map((session) => ({
|
||||
m_nsUsrName: session.username,
|
||||
nickname: session.nickname || session.username,
|
||||
avatar: session.avatar
|
||||
}))
|
||||
.filter((contact) => {
|
||||
if (!keyword) return true
|
||||
return (
|
||||
contact.m_nsUsrName.toLowerCase().includes(keyword) ||
|
||||
contact.nickname.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
public getAllGroupContacts(): Record<string, string> {
|
||||
if (!this.correctUserId) return {}
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||
const db = this.connectDb(dbPath)
|
||||
if (!db) return {}
|
||||
|
||||
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupContact').all() as {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
}[]
|
||||
db.close()
|
||||
|
||||
const groupContacts: Record<string, string> = {}
|
||||
for (const row of results) {
|
||||
if (row.m_nsUsrName && row.nickname) {
|
||||
const md5 = this.md5(row.m_nsUsrName)
|
||||
groupContacts[md5] = row.nickname
|
||||
for (const session of this.wcdb4Client.getSessions()) {
|
||||
if (session.username.endsWith('@chatroom')) {
|
||||
groupContacts[this.md5(session.username)] = session.nickname || session.username
|
||||
}
|
||||
}
|
||||
return groupContacts
|
||||
}
|
||||
|
||||
public getAllGroupMembers(): Record<string, string> {
|
||||
if (!this.correctUserId) return {}
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||
const db = this.connectDb(dbPath)
|
||||
if (!db) return {}
|
||||
|
||||
// 查询 GroupMember 表
|
||||
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupMember').all() as {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
}[]
|
||||
db.close()
|
||||
|
||||
const groupMembers: Record<string, string> = {}
|
||||
for (const row of results) {
|
||||
if (row.m_nsUsrName && row.nickname) {
|
||||
// 将 wxid (m_nsUsrName) 映射到昵称
|
||||
groupMembers[row.m_nsUsrName] = row.nickname
|
||||
const members: Record<string, string> = {}
|
||||
for (const session of this.wcdb4Client.getSessions()) {
|
||||
if (!session.username.endsWith('@chatroom')) continue
|
||||
for (const member of this.wcdb4Client.getGroupMembers(session.username)) {
|
||||
if (member.m_nsUsrName) {
|
||||
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
|
||||
}
|
||||
}
|
||||
}
|
||||
return groupMembers
|
||||
return members
|
||||
}
|
||||
|
||||
public getGroupMember(wxid: string): GroupMemberInfo | null {
|
||||
if (!this.correctUserId) return null
|
||||
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username || !username.endsWith('@chatroom')) return {}
|
||||
|
||||
// 检查缓存
|
||||
if (this.groupMemberCache.has(wxid)) {
|
||||
return this.groupMemberCache.get(wxid) || null
|
||||
const members: Record<string, string> = {}
|
||||
for (const member of this.wcdb4Client.getGroupMembers(username)) {
|
||||
if (member.m_nsUsrName) {
|
||||
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||
const db = this.connectDb(dbPath)
|
||||
if (!db) {
|
||||
this.groupMemberCache.set(wxid, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
'SELECT m_nsUsrName, nickname, m_nsHeadImgUrl FROM GroupMember WHERE m_nsUsrName = ?'
|
||||
)
|
||||
.get(wxid) as GroupMemberInfo
|
||||
|
||||
db.close()
|
||||
|
||||
// 写入缓存
|
||||
this.groupMemberCache.set(wxid, result || null)
|
||||
|
||||
return result || null
|
||||
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
||||
if (!chatroomId) return null
|
||||
return (
|
||||
this.wcdb4Client
|
||||
.getGroupMembers(chatroomId)
|
||||
.find((member) => member.m_nsUsrName === wxid) || null
|
||||
)
|
||||
}
|
||||
|
||||
public getAllChatTables(): { name: string; db_number: string }[] {
|
||||
return this.chatDb || []
|
||||
return this.wcdb4Client.getChatTables()
|
||||
}
|
||||
|
||||
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
|
||||
if (!this.chatDb || !this.correctUserId) return []
|
||||
public getMyAvatarUrl(): string | undefined {
|
||||
return this.wcdb4Client.getMyAvatarUrl()
|
||||
}
|
||||
|
||||
const tableName = `Chat_${userMd5}`
|
||||
const tableInfo = this.chatDb.find((t) => t.name === tableName)
|
||||
public getWcdb4Client(): Wcdb4Client {
|
||||
return this.wcdb4Client
|
||||
}
|
||||
|
||||
if (!tableInfo) {
|
||||
console.error(`No table found for ${tableName}`)
|
||||
return []
|
||||
}
|
||||
public close(): void {
|
||||
this.wcdb4Client.close()
|
||||
}
|
||||
|
||||
const dbPath = path.join(
|
||||
WechatDb.WECHAT_DIR,
|
||||
this.correctUserId,
|
||||
'Message',
|
||||
tableInfo.db_number
|
||||
)
|
||||
const db = this.connectDb(dbPath)
|
||||
if (!db) return []
|
||||
|
||||
try {
|
||||
let query = `SELECT * FROM ${tableName}`
|
||||
const conditions: string[] = []
|
||||
|
||||
// console.log(startTime, endTime, 'time range')
|
||||
|
||||
if (startTime) {
|
||||
conditions.push(`msgCreateTime >= ${startTime}`)
|
||||
}
|
||||
if (endTime) {
|
||||
conditions.push(`msgCreateTime <= ${endTime}`)
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`
|
||||
}
|
||||
|
||||
// 添加排序以确保存储正确的顺序
|
||||
query += ` ORDER BY msgCreateTime ASC`
|
||||
|
||||
const results = db.prepare(query).all() as unknown as WechatMessage[]
|
||||
db.close()
|
||||
return results
|
||||
} catch (e) {
|
||||
console.error(`Error querying messages for ${tableName}:`, e)
|
||||
db.close()
|
||||
return []
|
||||
}
|
||||
public getUserMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: Wcdb4MessageQueryOptions
|
||||
): WechatMessage[] {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||
...message,
|
||||
...message.raw
|
||||
}))
|
||||
}
|
||||
|
||||
public searchAllMessages(keyword: string): string | null {
|
||||
if (!this.correctUserId) return null
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const dbName = `msg_${i}.db`
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', dbName)
|
||||
if (!fs.existsSync(dbPath)) continue
|
||||
|
||||
const db = this.connectDb(dbPath)
|
||||
if (!db) continue
|
||||
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
|
||||
.all() as { name: string }[]
|
||||
for (const table of tables) {
|
||||
try {
|
||||
// 首先检查 msgContent 列是否存在以避免错误?
|
||||
// 或者像 Swift 代码中那样使用 try/catch "抑制错误"
|
||||
const results = db
|
||||
.prepare(`SELECT 1 FROM ${table.name} WHERE msgContent LIKE '%${keyword}%' LIMIT 1`)
|
||||
.get()
|
||||
if (results) {
|
||||
console.log(`Found keyword in ${table.name} (${dbName})`)
|
||||
db.close()
|
||||
return table.name
|
||||
}
|
||||
} catch {
|
||||
// 忽略没有 msgContent 的表
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
const lowerKeyword = keyword.trim().toLowerCase()
|
||||
if (!lowerKeyword) return null
|
||||
for (const session of this.wcdb4Client.getSessions()) {
|
||||
const found = this.wcdb4Client
|
||||
.getMessages(session.username)
|
||||
.some((message) => message.msgContent.toLowerCase().includes(lowerKeyword))
|
||||
if (found) return `Chat_${this.md5(session.username)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
public md5(str: string): string {
|
||||
return crypto.createHash('md5').update(str).digest('hex')
|
||||
return this.wcdb4Client.md5(str)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,303 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { Contact, Message } from '../shared/types'
|
||||
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
||||
import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test'
|
||||
import {
|
||||
DeleteGeneratedReportResult,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
} from '../shared/report-history'
|
||||
import type {
|
||||
DatabaseKeyEnvironment,
|
||||
DatabaseKeyStorageResult,
|
||||
DatabaseKeyValidationResult
|
||||
} from '../shared/database-key'
|
||||
import type {
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
SaveImageKeyRequest,
|
||||
TestImageDecryptionRequest
|
||||
} from '../shared/image-decryption'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AIConnectionTestResult,
|
||||
AIProviderConfig,
|
||||
AIProviderListResult,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
LegacyAIConfig
|
||||
} from '../shared/ai-provider'
|
||||
import type {
|
||||
ImageAnalysisRequest,
|
||||
ImageAnalysisResponse,
|
||||
ImageCandidate,
|
||||
ImageCandidateQuery,
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'voice'; duration?: number }
|
||||
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
|
||||
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
|
||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||
| {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
url?: string
|
||||
thumbUrl?: string
|
||||
encryptUrl?: string
|
||||
aeskey?: string
|
||||
}
|
||||
| {
|
||||
type: 'quote'
|
||||
title?: string
|
||||
content?: string
|
||||
sender?: string
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
}
|
||||
| { type: 'system'; content: string }
|
||||
| { type: 'unknown'; raw: string }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: {
|
||||
initDb: (key: string) => Promise<boolean>
|
||||
initDb: (
|
||||
key: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
getBootstrapCache: () => Promise<{
|
||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null>
|
||||
getContacts: (filter?: string) => Promise<Contact[]>
|
||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||
getCachedMessages: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
) => Promise<Message[]>
|
||||
getMessages: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
) => Promise<Message[]>
|
||||
getGroupSnapshot: (userMd5: string) => Promise<{
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
} | null>
|
||||
search: (keyword: string) => Promise<string | null>
|
||||
aiChat: (
|
||||
messages: { role: string; content: string }[],
|
||||
options?: { apiKey?: string; model?: string }
|
||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
options?: AIChatRequestOptions
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
usage?: {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
estimated?: boolean
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
listAIProviders: () => Promise<AIProviderListResult>
|
||||
getAIRuntimeConfig: () => Promise<AIRuntimeModelConfig>
|
||||
saveAIProvider: (provider: AIProviderConfig) => Promise<AIProviderListResult>
|
||||
deleteAIProvider: (providerId: string) => Promise<AIProviderListResult>
|
||||
setDefaultAIProvider: (providerId: string) => Promise<AIProviderListResult>
|
||||
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
|
||||
testAIVision: (request: AIVisionTestRequest) => Promise<AIVisionTestResult>
|
||||
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
|
||||
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
|
||||
getVoiceData: (
|
||||
sessionId: string,
|
||||
localId: number,
|
||||
createTime: number,
|
||||
svrId?: string | number
|
||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
parseMessage: (content: string, messageType: number) => Promise<ParsedContent>
|
||||
getImage: (
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
error?: string
|
||||
isThumb?: boolean
|
||||
filePath?: string
|
||||
}>
|
||||
getSticker: (
|
||||
cdnUrl?: string,
|
||||
md5?: string
|
||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
listGeneratedReports: () => Promise<ReportHistoryResult>
|
||||
saveGeneratedReport: (
|
||||
request: SaveGeneratedReportRequest
|
||||
) => Promise<SaveGeneratedReportResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
readDatabaseKeyClipboard: () => Promise<{
|
||||
success: boolean
|
||||
value?: string
|
||||
error?: string
|
||||
}>
|
||||
autoGetDbKey: (options?: { save?: boolean }) => Promise<{
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
code?: string
|
||||
saved?: boolean
|
||||
warning?: string
|
||||
}>
|
||||
autoGetImageKey: (options?: { save?: boolean }) => Promise<{
|
||||
success: boolean
|
||||
xorKey?: number
|
||||
aesKey?: string
|
||||
verified?: boolean
|
||||
error?: string
|
||||
imageXorKey?: string
|
||||
imageAesKey?: string
|
||||
settings?: {
|
||||
dbRoot: string
|
||||
apiEnabled: boolean
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
}>
|
||||
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
|
||||
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
|
||||
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
|
||||
testImageDecryption: (
|
||||
request: TestImageDecryptionRequest
|
||||
) => Promise<ImageDecryptionTestResult>
|
||||
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
|
||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
|
||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
getSettings: () => Promise<{
|
||||
settings: {
|
||||
dbRoot: string
|
||||
apiEnabled: boolean
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
settingsPath: string
|
||||
}>
|
||||
setSettings: (
|
||||
patch: Partial<{
|
||||
dbRoot: string
|
||||
apiEnabled: boolean
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}>
|
||||
) => Promise<{
|
||||
settings: {
|
||||
dbRoot: string
|
||||
apiEnabled: boolean
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
settingsPath: string
|
||||
}>
|
||||
getSelf: () => Promise<
|
||||
| {
|
||||
ready: true
|
||||
info: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
}
|
||||
| { ready: false }
|
||||
>
|
||||
testConnection: (key: string, accountRoot?: string) => Promise<DatabaseKeyValidationResult>
|
||||
reopenWithRoot: (accountRoot: string) => Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
info?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
}>
|
||||
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
||||
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
||||
disconnectDb: () => Promise<{ success: boolean; error?: string }>
|
||||
apiStatus: () => Promise<{
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
error?: string
|
||||
}>
|
||||
apiStart: (
|
||||
host?: string,
|
||||
port?: number
|
||||
) => Promise<{ running: boolean; host: string; port: number; error?: string }>
|
||||
apiStop: () => Promise<{ running: boolean; host: string; port: number; error?: string }>
|
||||
apiToggle: (enabled: boolean) => Promise<{
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
error?: string
|
||||
}>
|
||||
getReaderSkillStatus: () => Promise<{
|
||||
available: boolean
|
||||
version?: string
|
||||
filePath?: string
|
||||
directoryPath?: string
|
||||
source: 'development' | 'bundled'
|
||||
githubUrl: string
|
||||
error?: string
|
||||
}>
|
||||
readReaderSkill: () => Promise<{ success: boolean; content?: string; error?: string }>
|
||||
revealReaderSkill: () => Promise<{ success: boolean; error?: string }>
|
||||
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
|
||||
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
|
||||
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[] }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,138 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AIProviderConfig,
|
||||
AIVisionTestRequest,
|
||||
LegacyAIConfig
|
||||
} from '../shared/ai-provider'
|
||||
import type {
|
||||
ImageAnalysisRequest,
|
||||
ImageAnalysisResponse,
|
||||
ImageCandidate,
|
||||
ImageCandidateQuery,
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
|
||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
||||
getMessages: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
|
||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
||||
aiChat: (
|
||||
messages: { role: string; content: string }[],
|
||||
options?: { apiKey?: string; model?: string }
|
||||
) => ipcRenderer.invoke('ai:chat', messages, options),
|
||||
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String)
|
||||
aiChat: (messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
|
||||
ipcRenderer.invoke('ai:chat', messages, options),
|
||||
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
|
||||
getAIRuntimeConfig: () => ipcRenderer.invoke('ai:getRuntimeConfig'),
|
||||
saveAIProvider: (provider: AIProviderConfig) => ipcRenderer.invoke('ai:saveProvider', provider),
|
||||
deleteAIProvider: (providerId: string) => ipcRenderer.invoke('ai:deleteProvider', providerId),
|
||||
setDefaultAIProvider: (providerId: string) =>
|
||||
ipcRenderer.invoke('ai:setDefaultProvider', providerId),
|
||||
testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId),
|
||||
testAIVision: (request: AIVisionTestRequest) => ipcRenderer.invoke('ai:testVision', request),
|
||||
migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config),
|
||||
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
|
||||
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
|
||||
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
||||
parseMessage: (content: string, messageType: number) =>
|
||||
ipcRenderer.invoke('db:parseMessage', content, messageType),
|
||||
getImage: (
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
|
||||
ipcRenderer.invoke('report:saveGenerated', request),
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||
autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options),
|
||||
autoGetImageKey: (options?: { save?: boolean }) =>
|
||||
ipcRenderer.invoke('key:autoGetImageKey', options),
|
||||
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
||||
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
|
||||
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
||||
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
||||
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
||||
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
|
||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { type: string; json: string }
|
||||
): void => callback(payload)
|
||||
ipcRenderer.on('wcdb-change', listener)
|
||||
return () => ipcRenderer.removeListener('wcdb-change', listener)
|
||||
},
|
||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
|
||||
callback(payload)
|
||||
ipcRenderer.on('key:dbKeyStatus', listener)
|
||||
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
|
||||
},
|
||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
|
||||
callback(payload)
|
||||
ipcRenderer.on('key:imageKeyStatus', listener)
|
||||
return () => ipcRenderer.removeListener('key:imageKeyStatus', listener)
|
||||
},
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
setSettings: (patch) => ipcRenderer.invoke('settings:set', patch),
|
||||
getSelf: () => ipcRenderer.invoke('settings:getSelf'),
|
||||
testConnection: (key: string, accountRoot?: string) =>
|
||||
ipcRenderer.invoke('db:testConnection', key, accountRoot),
|
||||
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
||||
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
||||
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
||||
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
|
||||
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||
apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled),
|
||||
getReaderSkillStatus: () => ipcRenderer.invoke('api:skillStatus'),
|
||||
readReaderSkill: () => ipcRenderer.invoke('api:readSkill'),
|
||||
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
|
||||
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
|
||||
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
|
||||
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)
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Electron</title>
|
||||
<title>WechatExplorer</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/brand-icon.svg" />
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
|
||||
/>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" rx="48" fill="#022C22" />
|
||||
<rect x="0.5" y="0.5" width="255" height="255" rx="47.5" stroke="#064E3B" />
|
||||
<rect x="128" y="16.2742" width="158" height="158" rx="15" transform="rotate(45 128 16.2742)" stroke="#14B8A6" stroke-opacity="0.3" stroke-width="2" />
|
||||
<rect x="65" y="65" width="126" height="126" rx="11" stroke="#14B8A6" stroke-opacity="0.5" stroke-width="2" />
|
||||
<rect x="104" y="104" width="48" height="48" rx="24" fill="#14B8A6" />
|
||||
<rect x="192" y="48" width="16" height="16" rx="8" fill="#2DD4BF" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -1,103 +1,178 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { toPng } from 'html-to-image'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Message, Contact } from '../../../shared/types'
|
||||
import { ChatHeader } from './chat/ChatHeader'
|
||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||
import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { ExportRange } from './chat/ExportMenu'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
contentFilter?: string
|
||||
dateRange?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onCreateGroupReport?: () => void
|
||||
isAiLoading?: boolean
|
||||
}
|
||||
|
||||
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
|
||||
请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称
|
||||
请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
|
||||
- 整体评价
|
||||
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
|
||||
- 参与者(不超过5个人,将重复的人名去重)
|
||||
- 注意按时间排序,时间段(从日期几点到几点)
|
||||
- 过程(50到200字左右)
|
||||
- 评价(50字以下)
|
||||
- 生成这7天内热度最高的话题,27日到2日一共7天
|
||||
需要生成27, 28, 29, 30, 31, 1, 2日的话题总结
|
||||
- 分割线: ------------
|
||||
const MAX_RENDERED_MESSAGES = 600
|
||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨日',
|
||||
'7': '7 天',
|
||||
'30': '30 天',
|
||||
all: '全部'
|
||||
}
|
||||
|
||||
另外有以下要求:
|
||||
1. 每个话题结束使用------------分割
|
||||
2. 使用中文冒号
|
||||
3. 无需大标题
|
||||
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
|
||||
5. 每个话题详细写出参与者
|
||||
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> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
contentFilter,
|
||||
dateRange = 'today',
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData
|
||||
onRefreshData,
|
||||
onCreateGroupReport,
|
||||
isAiLoading = false
|
||||
}) => {
|
||||
const isGroupChat = contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||
const isGroupChat = Boolean(
|
||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||
)
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [showAvatar, setShowAvatar] = useState(false)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [imageScale, setImageScale] = useState(0.75)
|
||||
const [imageRotation, setImageRotation] = useState(0)
|
||||
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
|
||||
const imageViewerStageRef = useRef<HTMLDivElement>(null)
|
||||
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
|
||||
null
|
||||
)
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||
|
||||
const [colWidths, setColWidths] = useState([150, 100, 180, 400])
|
||||
const [resizingColIndex, setResizingColIndex] = useState<number | null>(null)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
|
||||
// AI Settings
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem('deepseek_api_key') || '')
|
||||
const [model, setModel] = useState('deepseek-chat')
|
||||
|
||||
const handleSaveSettings = (): void => {
|
||||
localStorage.setItem('deepseek_api_key', apiKey)
|
||||
setShowSettingsModal(false)
|
||||
AIChat()
|
||||
}
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
}
|
||||
setIsAtLatest(true)
|
||||
}, [])
|
||||
|
||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||
const target = event.currentTarget
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
setIsAtLatest(distanceToBottom <= 24)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [messages, scrollToBottom])
|
||||
|
||||
const startResizing = (index: number, e: React.MouseEvent): void => {
|
||||
e.preventDefault()
|
||||
setResizingColIndex(index)
|
||||
startXRef.current = e.clientX
|
||||
startWidthRef.current = colWidths[index]
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
setImageScale(1)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent): void => {
|
||||
if (resizingColIndex === null) return
|
||||
const diff = e.clientX - startXRef.current
|
||||
const newWidth = Math.max(50, startWidthRef.current + diff)
|
||||
const closeImagePreview = (): void => {
|
||||
setPreviewImage(null)
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
setColWidths((prev) => {
|
||||
const newCols = [...prev]
|
||||
newCols[resizingColIndex] = newWidth
|
||||
return newCols
|
||||
const zoomImage = (delta: number): void => {
|
||||
setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
|
||||
}
|
||||
|
||||
const resetImageTransform = (): void => {
|
||||
setImageScale(1)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleViewerWheel = (event: React.WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
|
||||
}
|
||||
|
||||
const handleViewerMouseDown = (event: React.MouseEvent): void => {
|
||||
event.preventDefault()
|
||||
imageDragRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
offsetX: imageOffset.x,
|
||||
offsetY: imageOffset.y
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewerMouseMove = (event: React.MouseEvent): void => {
|
||||
if (!imageDragRef.current) return
|
||||
const drag = imageDragRef.current
|
||||
setImageOffset({
|
||||
x: drag.offsetX + event.clientX - drag.x,
|
||||
y: drag.offsetY + event.clientY - drag.y
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = (): void => {
|
||||
setResizingColIndex(null)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
const handleViewerMouseUp = (): void => {
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
const handleExport = (days: number | 'all'): void => {
|
||||
useEffect(() => {
|
||||
if (!previewImage) return
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
const stage = imageViewerStageRef.current
|
||||
const preventBackgroundWheel = (event: WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
}
|
||||
stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
stage?.removeEventListener('wheel', preventBackgroundWheel)
|
||||
}
|
||||
}, [previewImage])
|
||||
|
||||
const handleExport = (days: ExportRange): void => {
|
||||
if (!messages.length) return
|
||||
|
||||
let filtered = messages
|
||||
@@ -156,366 +231,111 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const [summaryContent, setSummaryContent] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const AIChat = async (): Promise<void> => {
|
||||
if (!messages || messages.length === 0) {
|
||||
alert('当前没有消息可供总结')
|
||||
return
|
||||
}
|
||||
const filteredMessages = messages
|
||||
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
|
||||
.map((msg) => {
|
||||
const { img, id, isSender, ...rest } = msg
|
||||
return rest
|
||||
})
|
||||
const recentMessages = filteredMessages
|
||||
.map((msg) => {
|
||||
return `${msg.datetime} ${msg.from}: ${msg.content}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}`
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
console.log('正在请求AI...')
|
||||
const result = await window.api.aiChat(
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
{ apiKey, model }
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
console.log('AI Summary:', result.data)
|
||||
setSummaryContent(result.data)
|
||||
|
||||
// 等待状态更新和渲染
|
||||
setTimeout(() => {
|
||||
textToImage()
|
||||
setIsLoading(false) // 图片生成开始后停止加载
|
||||
}, 500)
|
||||
} else {
|
||||
console.error('AI Error:', result.error)
|
||||
alert(`AI 请求失败: ${result.error}`)
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI Call Failed:', error)
|
||||
alert('AI 请求发生错误')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const textToImage = async (): Promise<void> => {
|
||||
if (imageContainerRef.current) {
|
||||
try {
|
||||
const dataUrl = await toPng(imageContainerRef.current, {
|
||||
cacheBust: true,
|
||||
backgroundColor: '#ffffff',
|
||||
style: {
|
||||
transform: 'scale(1)'
|
||||
}
|
||||
})
|
||||
if (dataUrl && dataUrl.length > 100) {
|
||||
setGeneratedImage(dataUrl)
|
||||
} else {
|
||||
alert('生成图片为空')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate image', err)
|
||||
alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleCopyImage = async (): Promise<void> => {
|
||||
if (!generatedImage) return
|
||||
const result = await window.api.copyImage(generatedImage)
|
||||
if (result.success) {
|
||||
alert('复制成功')
|
||||
}
|
||||
}
|
||||
|
||||
const [displayLimit, setDisplayLimit] = useState(100)
|
||||
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
return messages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '分享消息,图片,表情包,视频')
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
const typeMatch = !filterTypes.includes(msg.type)
|
||||
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
|
||||
const renderedMessages = React.useMemo(
|
||||
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
|
||||
[filteredMessages]
|
||||
)
|
||||
|
||||
const visibleMessages = filteredMessages.slice(0, displayLimit)
|
||||
if (!contact) return <EmptyConversationState />
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<div className="empty-state">选择一条消息</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<div className="chat-header">
|
||||
<h2>{contact.m_nsNickName}</h2>
|
||||
<div className="window-controls"></div>
|
||||
</div>
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
dateRangeLabel={dateRangeLabel}
|
||||
loadedCount={messages.length}
|
||||
filteredCount={filteredMessages.length}
|
||||
contentFilter={contentFilter || ''}
|
||||
isAiLoading={isAiLoading}
|
||||
canExport={messages.length > 0}
|
||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshData={onRefreshData}
|
||||
onExport={handleExport}
|
||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||
/>
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
<MessageList
|
||||
contact={contact}
|
||||
messages={renderedMessages}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
listRef={messageListRef}
|
||||
bottomRef={messagesEndRef}
|
||||
onScroll={handleMessageListScroll}
|
||||
onImageClick={openImagePreview}
|
||||
/>
|
||||
<ChatStatusBar
|
||||
count={renderedMessages.length}
|
||||
showAvatar={showAvatar}
|
||||
isAtLatest={isAtLatest}
|
||||
onShowAvatarChange={setShowAvatar}
|
||||
onJumpToLatest={scrollToBottom}
|
||||
/>
|
||||
|
||||
<div className="message-list">
|
||||
<table className="chat-table" style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: colWidths[0], position: 'relative' }}>
|
||||
发送者
|
||||
<div className="column-resizer" onMouseDown={(e) => startResizing(0, e)} />
|
||||
</th>
|
||||
<th style={{ width: colWidths[1], position: 'relative' }}>
|
||||
类型
|
||||
<div className="column-resizer" onMouseDown={(e) => startResizing(1, e)} />
|
||||
</th>
|
||||
<th style={{ width: colWidths[2], position: 'relative' }}>
|
||||
时间
|
||||
<div className="column-resizer" onMouseDown={(e) => startResizing(2, e)} />
|
||||
</th>
|
||||
<th style={{ width: colWidths[3] }}>内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleMessages.map((msg) => (
|
||||
<tr key={msg.id}>
|
||||
<td
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={msg.from}
|
||||
>
|
||||
{msg.from}
|
||||
</td>
|
||||
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{msg.type}
|
||||
</td>
|
||||
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{msg.datetime}
|
||||
</td>
|
||||
<td style={{ wordBreak: 'break-all', display: 'flex', alignItems: 'center' }}>
|
||||
{showAvatar && msg?.img && (
|
||||
<img style={{ width: '40px', height: '40px' }} src={msg?.img}></img>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: !showAvatar ? 'flex' : 'block'
|
||||
}}
|
||||
>
|
||||
{(msg.name || contact.m_nsNickName) && (
|
||||
<div style={{ display: 'flex', fontSize: 18 }}>
|
||||
{isGroupChat ? msg.name : msg.from === 'user' ? contact.m_nsNickName : '我'}
|
||||
<span>{isGroupChat ? (msg.name ? ':' : '') : ':'} </span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 18,
|
||||
background: '#fff',
|
||||
margin: 4,
|
||||
padding: 4,
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredMessages.length > displayLimit && (
|
||||
<div style={{ textAlign: 'center', padding: '10px' }}>
|
||||
<button
|
||||
onClick={() => setDisplayLimit((prev) => prev + 100)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
加载更多 ({filteredMessages.length - displayLimit} 条剩余)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-toolbar">
|
||||
<label
|
||||
style={{ marginRight: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAvatar}
|
||||
onChange={(e) => setShowAvatar(e.target.checked)}
|
||||
style={{ marginRight: '5px' }}
|
||||
/>
|
||||
显示头像
|
||||
</label>
|
||||
<button className="toolbar-btn" onClick={onRefresh}>
|
||||
🔄 刷新聊天记录
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onRefreshData}>
|
||||
🔄 刷新数据
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport('all')}>
|
||||
📤 导出全部
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(0)}>
|
||||
🕒 导出今日
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(1)}>
|
||||
📅 导出昨日
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(7)}>
|
||||
📅 导出近7天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(30)}>
|
||||
📅 导出近30天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
|
||||
🤖 AI总结群聊
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: -1
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '820px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#fff',
|
||||
fontSize: '22px',
|
||||
color: '#000',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'sans-serif',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
ref={imageContainerRef}
|
||||
>
|
||||
{summaryContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载模态框 */}
|
||||
{isLoading && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
|
||||
<div style={{ fontSize: '16px', color: '#333' }}>正在生成 AI 总结...</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
||||
请稍候,生成后将自动转换为图片
|
||||
{previewImage && (
|
||||
<div className="image-viewer-overlay" onClick={closeImagePreview}>
|
||||
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="image-viewer-titlebar">
|
||||
<div className="image-viewer-tools">
|
||||
<span className="image-viewer-title">图片查看</span>
|
||||
<button onClick={() => zoomImage(-0.1)} title="缩小">
|
||||
−
|
||||
</button>
|
||||
<span className="image-viewer-zoom">{Math.round(imageScale * 100)}%</span>
|
||||
<button onClick={() => zoomImage(0.1)} title="放大">
|
||||
+
|
||||
</button>
|
||||
<span className="image-viewer-divider" />
|
||||
<button onClick={() => setImageRotation((prev) => prev - 90)} title="左旋转">
|
||||
↶
|
||||
</button>
|
||||
<button onClick={() => setImageRotation((prev) => prev + 90)} title="右旋转">
|
||||
↷
|
||||
</button>
|
||||
<button onClick={resetImageTransform} title="重置">
|
||||
⟲
|
||||
</button>
|
||||
</div>
|
||||
<button className="image-viewer-close" onClick={closeImagePreview} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片预览模态框 */}
|
||||
{generatedImage && (
|
||||
<div className="modal-overlay" onClick={() => setGeneratedImage(null)}>
|
||||
<div className="modal-content image-preview-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={generatedImage}
|
||||
alt="Generated Summary"
|
||||
style={{ maxWidth: '100%', maxHeight: '80vh', border: '1px solid #ccc' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '10px',
|
||||
display: 'flex',
|
||||
// justifyContent: 'flex-end',
|
||||
gap: '10px'
|
||||
}}
|
||||
ref={imageViewerStageRef}
|
||||
className="image-viewer-stage"
|
||||
onWheel={handleViewerWheel}
|
||||
onMouseDown={handleViewerMouseDown}
|
||||
onMouseMove={handleViewerMouseMove}
|
||||
onMouseUp={handleViewerMouseUp}
|
||||
onMouseLeave={handleViewerMouseUp}
|
||||
>
|
||||
<button
|
||||
onClick={handleCopyImage}
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="图片预览"
|
||||
draggable={false}
|
||||
style={{
|
||||
padding: '8px 15px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px'
|
||||
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`
|
||||
}}
|
||||
>
|
||||
📋 复制图片
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGeneratedImage(null)}
|
||||
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Settings Modal */}
|
||||
{showSettingsModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>AI 设置</h3>
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>模型:</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
<option value="deepseek-chat">DeepSeek Chat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>API Key:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Enter your DeepSeek API Key"
|
||||
style={{ width: '95%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button onClick={() => setShowSettingsModal(false)}>取消</button>
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
style={{
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 15px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
生成总结
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
interface ImageBubbleProps {
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
sessionId?: string
|
||||
isThumb?: boolean
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
export function ImageBubble({
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
isThumb = false,
|
||||
onImageClick
|
||||
}: ImageBubbleProps): JSX.Element {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isThumbnail, setIsThumbnail] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (imageUrl || loading) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
} else {
|
||||
setError(result.error || '加载图片失败')
|
||||
}
|
||||
} catch {
|
||||
setError('加载图片失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
const element = containerRef.current
|
||||
if (!element || typeof IntersectionObserver === 'undefined') {
|
||||
const timer = window.setTimeout(() => void loadImage(), 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||
observer.disconnect()
|
||||
void loadImage()
|
||||
},
|
||||
{ rootMargin: '400px 0px' }
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [error, imageUrl, loadImage, loading])
|
||||
|
||||
const handleCopy = async (event: MouseEvent): Promise<void> => {
|
||||
event.stopPropagation()
|
||||
if (!imageUrl) return
|
||||
await window.api.copyImage(imageUrl)
|
||||
alert('图片已复制')
|
||||
}
|
||||
|
||||
const handleClick = async (): Promise<void> => {
|
||||
if (!imageUrl && !error) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
if (imageUrl) onImageClick?.(imageUrl)
|
||||
return
|
||||
}
|
||||
|
||||
if (upgrading) {
|
||||
if (imageUrl) onImageClick?.(imageUrl)
|
||||
return
|
||||
}
|
||||
|
||||
setUpgrading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
onImageClick?.(result.data)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the already visible image.
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
}
|
||||
|
||||
if (imageUrl) onImageClick?.(imageUrl)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="image-bubble image-loading" onClick={handleClick}>
|
||||
<div className="image-loading-skeleton" aria-hidden />
|
||||
<div className="image-quality-badge">加载中</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="image-bubble image-error" onClick={loadImage}>
|
||||
<div className="image-error-text">{error || '图片未缓存'}</div>
|
||||
<div className="image-quality-badge">加载失败</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<div ref={containerRef} className="image-bubble image-placeholder">
|
||||
<div className="image-loading-skeleton" aria-hidden />
|
||||
<div className="image-quality-badge">加载中</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||
<img src={imageUrl} alt="图片" className="image-content" />
|
||||
{(upgrading || isThumbnail) && (
|
||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||
)}
|
||||
<div className="image-actions">
|
||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ParsedContent } from '../../../shared/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
||||
|
||||
const stickerDataUrlCache = new Map<string, string>()
|
||||
|
||||
interface RichMessageBubbleProps {
|
||||
contentData: ParsedContent
|
||||
}
|
||||
|
||||
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
|
||||
switch (contentData.type) {
|
||||
case 'location':
|
||||
return <LocationBubble data={contentData} />
|
||||
case 'card':
|
||||
return <CardBubble data={contentData} />
|
||||
case 'share':
|
||||
return <ShareBubble data={contentData} />
|
||||
case 'voip':
|
||||
return <VoipBubble data={contentData} />
|
||||
case 'sticker':
|
||||
return <StickerBubble data={contentData} />
|
||||
case 'quote':
|
||||
return <QuoteBubble data={contentData} />
|
||||
case 'system':
|
||||
return <SystemBubble data={contentData} />
|
||||
case 'unknown':
|
||||
return (
|
||||
<div className="message-text">
|
||||
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')}
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return <div className="message-text">[不支持的消息类型]</div>
|
||||
}
|
||||
}
|
||||
|
||||
function LocationBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'location' }>
|
||||
}): JSX.Element {
|
||||
const { poiname, label, lat, lng } = data
|
||||
const locationText = poiname || label || '位置'
|
||||
const hasCoords = lat !== 0 || lng !== 0
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (hasCoords) {
|
||||
const url = `https://maps.apple.com/?q=${encodeURIComponent(locationText)}&ll=${lat},${lng}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
|
||||
<div className="location-icon">位置</div>
|
||||
<div className="location-info">
|
||||
<div className="location-name">{locationText}</div>
|
||||
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
|
||||
{hasCoords && (
|
||||
<div className="location-coords">
|
||||
{lat.toFixed(6)}, {lng.toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }): JSX.Element {
|
||||
const { username, nickname } = data
|
||||
|
||||
const handleCopy = (e: MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(username)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-message">
|
||||
<div className="card-avatar">{(nickname || username).charAt(0).toUpperCase()}</div>
|
||||
<div className="card-info">
|
||||
<div className="card-nickname">{nickname || '未知'}</div>
|
||||
<div className="card-username" onClick={handleCopy} title="点击复制">
|
||||
{username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
||||
const { title, des, url, appname } = data
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const urlHost = getUrlHost(url)
|
||||
|
||||
return (
|
||||
<div className="share-message" onClick={handleClick}>
|
||||
{appname && <div className="share-appname">{appname}</div>}
|
||||
<div className="share-title">{title || '链接'}</div>
|
||||
{des && <div className="share-desc">{des}</div>}
|
||||
{urlHost && <div className="share-url">{urlHost}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
||||
const { status, roomType, duration } = data
|
||||
const isVideo = roomType === 1
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds) return ''
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
if (mins > 0) {
|
||||
return `${mins}分${secs}秒`
|
||||
}
|
||||
return `${secs}秒`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voip-message">
|
||||
<span className="voip-icon">{isVideo ? '视频' : '通话'}</span>
|
||||
<span className="voip-status">
|
||||
{status}
|
||||
{duration ? ` ${formatDuration(duration)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StickerBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'sticker' }>
|
||||
}): JSX.Element {
|
||||
const { md5, url, thumbUrl } = data
|
||||
const sourceUrl = url || thumbUrl || ''
|
||||
const cacheKey = md5 || sourceUrl
|
||||
const [displayUrl, setDisplayUrl] = useState(() =>
|
||||
cacheKey ? stickerDataUrlCache.get(cacheKey) || '' : ''
|
||||
)
|
||||
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey || displayUrl || error) return
|
||||
|
||||
let cancelled = false
|
||||
window.api
|
||||
.getSticker(sourceUrl, md5)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.data) {
|
||||
stickerDataUrlCache.set(cacheKey, result.data)
|
||||
setDisplayUrl(result.data)
|
||||
setError(false)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [cacheKey, displayUrl, error, md5, sourceUrl])
|
||||
|
||||
if (displayUrl) {
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<img src={displayUrl} alt="表情包" className="sticker-image" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">表情包加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
|
||||
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
|
||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||
const replyText = data.content || data.title || ''
|
||||
const quotedSender = data.quotedSender || data.sender || ''
|
||||
|
||||
return (
|
||||
<div className="quote-message">
|
||||
<div className="quoted-message">
|
||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
||||
</div>
|
||||
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemBubble({ data }: { data: Extract<ParsedContent, { type: 'system' }> }): JSX.Element {
|
||||
return <div className="message-text">{renderWechatEmojiText(data.content || '[系统消息]')}</div>
|
||||
}
|
||||
|
||||
function getUrlHost(url?: string): string {
|
||||
if (!url) return ''
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
|
||||
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
interface SelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
interface AppSettings {
|
||||
dbRoot: string
|
||||
apiEnabled: boolean
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
|
||||
interface ApiState {
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface AiModelConfig {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
model: string
|
||||
}
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbKey: string
|
||||
aiModelConfig: AiModelConfig
|
||||
onClose: () => void
|
||||
onDbKeyChange: (key: string) => void
|
||||
onAiModelConfigChange: (config: AiModelConfig) => void
|
||||
onSaveAiModelConfig: () => void
|
||||
onDbRootChanged: () => void
|
||||
}
|
||||
|
||||
const AI_MODEL_OPTIONS = [
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||
{ value: 'gpt-4o', label: 'GPT-4o' },
|
||||
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
|
||||
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
|
||||
{ value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' },
|
||||
{ value: 'moonshot-v1-8k', label: 'Moonshot V1' }
|
||||
]
|
||||
|
||||
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
open,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbKey,
|
||||
aiModelConfig,
|
||||
onClose,
|
||||
onDbKeyChange,
|
||||
onAiModelConfigChange,
|
||||
onSaveAiModelConfig,
|
||||
onDbRootChanged
|
||||
}) => {
|
||||
const isWindows = window.electron.process.platform === 'win32'
|
||||
const dbRootPlaceholder = isWindows
|
||||
? 'C:\\Users\\你\\Documents\\WeChat Files'
|
||||
: '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
const [settingsPath, setSettingsPath] = useState('')
|
||||
const [apiState, setApiState] = useState<ApiState | null>(null)
|
||||
const [testStatus, setTestStatus] = useState<
|
||||
{ kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string }
|
||||
>({ kind: 'idle', message: '' })
|
||||
const [reopenStatus, setReopenStatus] = useState<string>('')
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState<{
|
||||
kind: 'idle' | 'ok' | 'fail'
|
||||
message: string
|
||||
}>({ kind: 'idle', message: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
void refresh()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
return window.api.onImageKeyStatus(({ message }) => {
|
||||
setImageKeyStatus({ kind: 'idle', message })
|
||||
})
|
||||
}, [open])
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const [{ settings, settingsPath }, api] = await Promise.all([
|
||||
window.api.getSettings(),
|
||||
window.api.apiStatus()
|
||||
])
|
||||
setSettings(settings)
|
||||
setSettingsPath(settingsPath)
|
||||
setApiState(api)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
async function handleSave(patch: Partial<AppSettings>): Promise<void> {
|
||||
if (!settings) return
|
||||
setBusy(true)
|
||||
const next = await window.api.setSettings(patch)
|
||||
setSettings(next.settings)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
async function handleTest(): Promise<void> {
|
||||
setTestStatus({ kind: 'idle', message: '测试中...' })
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await window.api.testConnection(dbKey, settings?.dbRoot)
|
||||
if (result.success) {
|
||||
setTestStatus({
|
||||
kind: 'ok',
|
||||
message: '连接成功',
|
||||
wxid: result.wxid,
|
||||
accountRoot: result.accountRoot
|
||||
})
|
||||
if (result.accountRoot && settings && result.accountRoot !== settings.dbRoot) {
|
||||
const next = await window.api.setSettings({ dbRoot: result.accountRoot })
|
||||
setSettings(next.settings)
|
||||
}
|
||||
} else {
|
||||
setTestStatus({ kind: 'fail', message: result.error || '连接失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReopen(): Promise<void> {
|
||||
if (!settings) return
|
||||
setBusy(true)
|
||||
setReopenStatus('重新初始化中...')
|
||||
try {
|
||||
const result = await window.api.reopenWithRoot(settings.dbRoot)
|
||||
if (result.success) {
|
||||
setReopenStatus(`已重新打开:${result.info?.wxid || '未知'}`)
|
||||
onDbRootChanged()
|
||||
} else {
|
||||
setReopenStatus(result.error || '重新打开失败')
|
||||
}
|
||||
} catch (error) {
|
||||
setReopenStatus(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApiToggle(enabled: boolean): Promise<void> {
|
||||
setBusy(true)
|
||||
await handleSave({ apiEnabled: enabled })
|
||||
const state = await window.api.apiToggle(enabled)
|
||||
setApiState(state)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
async function handleApiRestart(): Promise<void> {
|
||||
if (!settings) return
|
||||
setBusy(true)
|
||||
await window.api.apiStop()
|
||||
const state = await window.api.apiStart(settings.apiHost, settings.apiPort)
|
||||
setApiState(state)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
async function handleAutoGetImageKey(): Promise<void> {
|
||||
if (!settings) return
|
||||
setBusy(true)
|
||||
setImageKeyStatus({ kind: 'idle', message: '正在扫描微信内存获取图片密钥...' })
|
||||
try {
|
||||
const result = await window.api.autoGetImageKey()
|
||||
if (!result.success || !result.aesKey) {
|
||||
setImageKeyStatus({ kind: 'fail', message: result.error || '图片密钥获取失败' })
|
||||
return
|
||||
}
|
||||
const imageXorKey =
|
||||
result.imageXorKey ||
|
||||
(typeof result.xorKey === 'number'
|
||||
? `0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`
|
||||
: settings.imageXorKey)
|
||||
const imageAesKey = result.imageAesKey || result.aesKey
|
||||
setSettings(result.settings || { ...settings, imageXorKey, imageAesKey })
|
||||
setImageKeyStatus({
|
||||
kind: 'ok',
|
||||
message: result.verified ? '图片密钥已获取并校验通过' : '图片密钥已获取,未完成模板校验'
|
||||
})
|
||||
} catch (error) {
|
||||
setImageKeyStatus({
|
||||
kind: 'fail',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={onClose}>
|
||||
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="settings-header">
|
||||
<h2>设置</h2>
|
||||
<button className="settings-close" onClick={onClose} title="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
{/* 自我信息卡片 */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">账号信息</div>
|
||||
{dbReady && selfInfo ? (
|
||||
<div className="settings-self">
|
||||
<div className="settings-self-avatar">
|
||||
{selfInfo.avatar ? (
|
||||
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-self-info">
|
||||
<div className="settings-self-nickname">{selfInfo.nickname}</div>
|
||||
<div className="settings-self-wxid">{selfInfo.wxid}</div>
|
||||
<div className="settings-self-account">{selfInfo.accountRoot}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-self-empty">尚未连接数据库</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 测试连接 */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">连接测试</div>
|
||||
<div className="settings-row">
|
||||
<button
|
||||
className="settings-btn settings-btn-primary"
|
||||
onClick={handleTest}
|
||||
disabled={busy || !dbKey}
|
||||
>
|
||||
测试连接
|
||||
</button>
|
||||
{testStatus.kind !== 'idle' && (
|
||||
<span className={`settings-status ${testStatus.kind}`}>
|
||||
{testStatus.kind === 'ok' ? '✓' : '✗'} {testStatus.message}
|
||||
{testStatus.wxid ? ` · ${testStatus.wxid}` : ''}
|
||||
{testStatus.accountRoot ? ` · ${testStatus.accountRoot}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
使用当前密钥 + 下方配置的根目录尝试打开数据库,只校验不持久化。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 解密密钥 */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">解密密钥</div>
|
||||
<div className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={dbKey}
|
||||
onChange={(e) => onDbKeyChange(e.target.value)}
|
||||
placeholder="64 位 hex 密钥,如 0x..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
密钥通过系统 safeStorage 加密保存在本机,不会上传任何服务器。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 图片解密密钥 */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">图片解密密钥</div>
|
||||
<div className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={settings?.imageKeyRoot || settings?.dbRoot || ''}
|
||||
onChange={(e) =>
|
||||
setSettings(settings ? { ...settings, imageKeyRoot: e.target.value } : null)
|
||||
}
|
||||
onBlur={(e) => handleSave({ imageKeyRoot: e.target.value })}
|
||||
placeholder={dbRootPlaceholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input settings-input-quarter"
|
||||
value={settings?.imageXorKey ?? ''}
|
||||
onChange={(e) =>
|
||||
setSettings(settings ? { ...settings, imageXorKey: e.target.value } : null)
|
||||
}
|
||||
onBlur={(e) => handleSave({ imageXorKey: e.target.value })}
|
||||
placeholder="XOR Key,如 0x40"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={settings?.imageAesKey ?? ''}
|
||||
onChange={(e) =>
|
||||
setSettings(settings ? { ...settings, imageAesKey: e.target.value } : null)
|
||||
}
|
||||
onBlur={(e) => handleSave({ imageAesKey: e.target.value })}
|
||||
placeholder="AES Key,16 位字符"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button className="settings-btn" onClick={handleAutoGetImageKey} disabled={busy}>
|
||||
内存扫描图片密钥
|
||||
</button>
|
||||
</div>
|
||||
{imageKeyStatus.message && (
|
||||
<div className={`settings-status ${imageKeyStatus.kind}`}>
|
||||
{imageKeyStatus.kind === 'ok' ? '✓ ' : imageKeyStatus.kind === 'fail' ? '✗ ' : ''}
|
||||
{imageKeyStatus.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-hint">
|
||||
目录默认使用数据库根目录,用于查找图片模板文件。Windows 会直接扫描微信内存,请先在微信中打开 2-3 张图片大图。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 数据库根目录 */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">数据库根目录</div>
|
||||
<div className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={settings?.dbRoot ?? ''}
|
||||
onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)}
|
||||
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
|
||||
placeholder={dbRootPlaceholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<button
|
||||
className="settings-btn"
|
||||
onClick={handleReopen}
|
||||
disabled={busy || !dbReady}
|
||||
>
|
||||
应用并重新初始化
|
||||
</button>
|
||||
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
可填写微信数据总目录或具体账号目录。Windows 通常是 Documents\WeChat Files,macOS 通常是 xwechat_files;程序会自动选择包含 db_storage/session.db 的账号目录。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">AI 模型配置</div>
|
||||
<div className="settings-row">
|
||||
<select
|
||||
className="settings-input settings-input-half"
|
||||
value={aiModelConfig.model}
|
||||
onChange={(event) =>
|
||||
onAiModelConfigChange({ ...aiModelConfig, model: event.target.value })
|
||||
}
|
||||
>
|
||||
{AI_MODEL_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="settings-btn" onClick={onSaveAiModelConfig}>
|
||||
保存 AI 配置
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={aiModelConfig.baseURL}
|
||||
onChange={(event) =>
|
||||
onAiModelConfigChange({ ...aiModelConfig, baseURL: event.target.value })
|
||||
}
|
||||
placeholder="https://api.deepseek.com"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<input
|
||||
type="password"
|
||||
className="settings-input"
|
||||
value={aiModelConfig.apiKey}
|
||||
onChange={(event) =>
|
||||
onAiModelConfigChange({ ...aiModelConfig, apiKey: event.target.value })
|
||||
}
|
||||
placeholder="API Key"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
所选内容会发送至你配置的模型服务进行处理。配置沿用原有本地
|
||||
localStorage 保存方式。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API 服务 */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">本地 HTTP API</div>
|
||||
<div className="settings-row">
|
||||
<label className="settings-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.apiEnabled ?? false}
|
||||
onChange={(e) => handleApiToggle(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span>启用 API 服务(127.0.0.1:6131)</span>
|
||||
</label>
|
||||
{apiState && (
|
||||
<span className={`settings-status ${apiState.running ? 'ok' : 'fail'}`}>
|
||||
{apiState.running ? '运行中' : '已停止'}
|
||||
{apiState.error ? ` · ${apiState.error}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input settings-input-half"
|
||||
value={settings?.apiHost ?? ''}
|
||||
onChange={(e) => setSettings(settings ? { ...settings, apiHost: e.target.value } : null)}
|
||||
onBlur={(e) => handleSave({ apiHost: e.target.value })}
|
||||
placeholder="host"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="settings-input settings-input-quarter"
|
||||
value={settings?.apiPort ?? 6131}
|
||||
onChange={(e) =>
|
||||
setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null)
|
||||
}
|
||||
onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })}
|
||||
placeholder="port"
|
||||
/>
|
||||
<button className="settings-btn" onClick={handleApiRestart} disabled={busy}>
|
||||
重启 API
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-hint">
|
||||
API 仅本机访问,无鉴权。关闭后 Claude / Codex 等客户端无法读取聊天数据。
|
||||
<br />
|
||||
配置文档:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 配置文件位置 */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">配置文件</div>
|
||||
<div className="settings-row">
|
||||
<code className="settings-path">{settingsPath}</code>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||