Compare commits

...
36 Commits
Author SHA1 Message Date
电摇小子 d079d45279 feat: 更换日报功能模板
- ChatWindow 模型下拉框新增 deepseek-v4-pro /
  deepseek-v4-flash
  - 主进程 model 兜底值改为 deepseek-v4-flash
  - 同步更新 .env.example 默认模型
  - 修复旧 localStorage 中 gpt-5.5 等不支持的模型导致的
  400 报错
2026-07-10 15:40:35 +08:00
电摇小子 b39cb362f7 Merge branch 'fix/xkey' 2026-07-10 10:38:39 +08:00
电摇小子 f9e629948d Merge branch 'feat/local-http-api' 2026-07-09 10:37:20 +08:00
电摇小子 a0b163823d fix: 修复登录 2026-07-09 10:37:11 +08:00
电摇小子 3eb4438f4c feat: 群日报 9 宫格头像自动反推 + 持续时长语义 + 自动登录
- group-report-service: enrichAvatarsFromGroup 从群成员快照反推真头像
  - group-report-service: 修 SVG data URL 正则,fallback 现在能正常嵌入
  - group-report (shared): GroupReportMetadata/Result 加 talker/warnings 字段
  - timeSpan 改为持续时长(\"1 h\" / \"30 min\" / \"2 d\" 紧凑半角)
  - App.tsx 启动自动连接(env var + safeStorage)
  - Wcdb4Client 父目录自动解析为最新 wxid
  - HTTP server EADDRINUSE 友好提示 + 指数退避
  - installSafeConsole 修 EPIPE crash
  - SettingsPanel 测试连接后自动更新 dbRoot
2026-07-08 16:40:03 +08:00
电摇小子 7932ea2f54 feat: 本地 HTTP API + 设置面板 + 账号自助入口
让 WechatExplorer 既能用图形界面浏览聊天记录,也能作为本机 MCP 数据源被
Claude / Codex 等客户端通过 127.0.0.1:6131 直接拉取。

本机 HTTP API
  - 新增 http-server: 8 个端点,覆盖 health / current_time / contact /
    chatroom / recent_chat / chatlog / group_snapshot / resolve / report
  - 时间参数支持 YYYY-MM-DD / YYYY-MM-DD/HH:mm / Unix 秒级;日期单独使用
    时自动补到 00:00:00~23:59:59,避免漏消息
  - apiServer 单例支持动态启停,启动失败返回 friendlyMessage(把
    EADDRINUSE 翻译成"端口已被占用"的中文错误并附 4 次重试)

数据库根目录与自服务入口
  - Wcdb4Client 接受 accountRoot 时会自动解析:父目录下找最新含
    db_storage 的 wxid 子目录;设置面板"测试连接"成功后回写解析后的
    精确路径
  - 抽 chat-service.ts:IPC 和 HTTP 共享 listContacts / listMessages /
    getGroupSnapshot / searchMessages / getSelfAccountInfo / testConnection
    / reopenWithRoot
  - WechatDb 接受可选 accountRoot;设置面板新增"应用并重新初始化"
    按钮,改完 dbRoot 立即生效
  - 新增 settings-store.ts,dbRoot / apiEnabled / apiHost / apiPort 落到
    userData/settings.json

主进程健壮性
  - 新增 safe-log.ts 包一层 console.log/warn/error,electron-vite 关闭
    子进程 stderr 后写 EPIPE 不再炸 IPC handler(原 main build 启动时
    即 installSafeConsole)
2026-07-07 13:58:10 +08:00
电摇小子 4d14ce867c docs: README 2026-07-07 13:52:47 +08:00
电摇小子 7f7d673abb chore: 修改打包名推送 2026-07-03 14:11:49 +08:00
电摇小子andClaude Opus 4.7 4bba8bd871 refactor: 移除微信 3.0 解密路径, 统一走 4.0 WCDB
- wechat-db.ts 删除 SQLCipher 回退、connectDb、getUser、tryOpenWechat4 等 3.0 专属逻辑
- image-decrypt-service.ts 删除 decryptDatV3 与 version === 0 分支
- index.ts 去除 wcdb4Client 冗余空值检查
- 移除 better-sqlite3-multiple-ciphers 依赖
- README 版本前置改为 4.0+

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 10:37:51 +08:00
电摇小子 30c47af1f9 fix: 修复 macOS 打包版 WCDB 初始化 2026-07-03 10:05:50 +08:00
电摇小子 77aa74dd92 style: 修改样式, 去除日志 2026-07-02 10:24:31 +08:00
电摇小子 f96ada906c feat: 实现退群监控 2026-06-30 16:04:01 +08:00
电摇小子andClaude Opus 4.7 a50e903e55 chore: bump version to 2.0.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 14:39:52 +08:00
电摇小子 5a87a10d12 feat: 添加微信数据库实时监控,聊天记录自动刷新 2026-06-30 14:36:45 +08:00
电摇小子 10dc74e2a1 feat: 支持自动获取并安全保存数据库密钥 2026-06-30 10:12:25 +08:00
电摇小子 c00d581d9e feat: 修改群聊日报生成与群成员信息展示
- 支持结构化 AI 群聊日报及移动端长图导出
- 支持日报时间范围和消息类型筛选
- 支持群成员及当前用户群昵称解析
- 移除消息加载更多并优化图片懒加载
- 增强头像处理与敏感密钥日志保护
2026-06-29 16:43:01 +08:00
电摇小子 f6f8bc33f9 feat: 支持微信 4.0 数据库解密与富媒体消息查看
- 接入微信 4.0 WCDB 数据库解密,兼容微信 3.0 解密方式
- 支持图片解密及图片预览、缩放、旋转和拖动
- 支持语音解密与播放
- 支持表情包、引用、分享、名片、位置及通话消息解析
- 支持联系人和群聊真实头像
- 优化群聊、联系人分类及排序
- 修复复合消息类型和压缩消息内容解析
- 完善原生解密库打包及数据库错误提示
2026-06-29 14:51:45 +08:00
电摇小子 7196adb84c chore: 去除release脚本 2026-06-01 11:36:04 +08:00
电摇小子andClaude Opus 4.7 a740a50e79 fix: 使用 pnpm/action-setup@v4 安装 pnpm
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:04:19 +08:00
电摇小子andClaude Opus 4.7 ffe797b3e4 fix: 使用 corepack 启用 pnpm 替代 npm install -g
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:01:37 +08:00
电摇小子andClaude Opus 4.7 9cd6061c0f fix: 使用 pnpm@7 匹配项目要求
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:39:23 +08:00
电摇小子andClaude Opus 4.7 67d86f7834 fix: 修复 release workflow - 改用 npm 安装 pnpm 和 artifacts 上传
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:38:03 +08:00
电摇小子andClaude Opus 4.7 a6138fd62b chore: 添加 GitHub Actions 自动构建 release workflow
- 推送 v* 标签时自动在 macOS 多架构构建 .dmg
- 自动创建 GitHub Release 并附加构建产物
- 升级版本至 v1.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:20:32 +08:00
电摇小子andClaude Opus 4.7 d1763757be docs: 更新 README,添加多模型服务支持说明
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:12:57 +08:00
电摇小子andClaude Opus 4.7 a4dfa542d1 feat: 支持多模型服务配置 (Key/Base URL/模型名)
- 后端 ai:chat 支持 baseURL 参数,可配置任意 OpenAI 兼容 API
- 前端 UI 增加 Base URL 输入框和模型选择下拉 (DeepSeek/GPT-4o/Claude/Moonshot)
- 配置通过 localStorage 持久化,支持从 .env 文件读取默认値
- 统一错误提示,移除 DeepSeek 硬编码引用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:11:25 +08:00
电摇小子 9fb1d14c41 doc: 更新文档 2026-04-08 11:01:03 +08:00
电摇小子 eb60ce4984 chore: 提升版本更新README 2026-01-14 16:28:37 +08:00
电摇小子 061086f0ad docs: 更新README 2026-01-04 16:08:37 +08:00
qingmaoandGitHub d1cb6277f9 Merge pull request #2 from Wxw-Gu/fix-issue-1
Fixes #1: 修复个人聊天界面和导出csv文件不显示名称问题
2026-01-04 15:57:10 +08:00
电摇小子 02baed9bb6 fix: 修复个人聊天界面和导出csv文件不显示名称问题,fix #1 2026-01-04 15:53:26 +08:00
电摇小子 55b792b23c chore: 提升版本 2025-12-31 09:54:59 +08:00
电摇小子 7c3fa728f7 feat: 优化 2025-12-31 09:50:46 +08:00
电摇小子 755d11f71c feat: 增加聊天窗口头像显示切换功能并更新示例图片 2025-12-30 18:06:30 +08:00
电摇小子 eeded5fc46 chore: 移除 LICENSE 2025-12-30 17:29:53 +08:00
电摇小子 ac14864655 docs: 更新README 2025-12-29 10:22:58 +08:00
电摇小子 80316b1db7 docs: 更新README 2025-12-19 17:52:03 +08:00
54 changed files with 11472 additions and 909 deletions
+12 -3
View File
@@ -1,8 +1,17 @@
# WeChat Database Key (Optional, can be entered in UI)
VITE_DB_KEY=
# DeepSeek API Key (Optional, can be entered in UI)
# 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)
VITE_FILTER_MSG_TYPES=分享消息,图片,表情包,视频
# 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 used to decrypt image .dat files in WeChat 4.0+
# 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
View File
@@ -5,3 +5,4 @@ out
.DS_Store
.eslintcache
*.log*
.omc
+1 -1
View File
@@ -1,6 +1,6 @@
{
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 WechatExplorer Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+125 -52
View File
@@ -3,90 +3,163 @@
MAC系统 获取微信聊天记录 AI一键生成群聊总结
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
## 📸 预览
## 项目说明
<div style="display: flex; justify-content: space-between;">
<img src="./public/WX20251215-155324.png" alt="预览图1" width="48%" />
<img src="./public/WX20251215-155524.png" alt="预览图2" width="48%" />
</div>
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
## ✨ 功能特性
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示
- **全局搜索**: 快速搜索聊天内容。
- **AI 智能总结**: 集成 DeepSeek AI,一键总结群聊精华内容,生成话题报告。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot,一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
## 🛠️ 技术栈
## 📸 预览
- **Electron**: 跨平台桌面应用框架
- **React**: 用于构建用户界面
- **TypeScript**: 提供类型安全
- **Vite**: 极速构建工具
- **Better-SQLite3**: 高性能 SQLite 数据库操作
- **OpenAI SDK**: 用于调用 DeepSeek API
### 群聊总结长图
<img src="./public/example1.png" alt="总结图片" />
### 软件主页面
<img src="./public/example2.png" alt="软件主页面" />
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
## 📦 安装说明
1. 下载下方的 `xxx.dmg` 文件。
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
`系统设置 -> 隐私与安全性 -> 仍要打开`
## 🚀 快速开始
### 前置要求
### 使用前置要求
- 微信<=4.0 需要获取自己微信本地数据库的密码, 获取方式参考: [Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/)
- 如果无法获取本地数据库密码 则无法使用当前项目
- **微信版本**:
- 微信 4.0+: 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
- 如无法获取本地数据库密码,则无法使用当前项目
- Node.js (推荐 v16+)
- pnpm (推荐) 或 npm
- pnpm@7
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
- [DeepSeek API Key](https://www.deepseek.com/) (用于 AI 总结功能)
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
### 安装
### 环境变量配置 (.env)
可选配置项,可在 `.env` 文件中设置:
| 变量名 | 说明 | 示例 |
| ----------------------- | --------------------------- | --------------------------- |
| `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. 从 WeFlow/Chatlog 设置中导出
2. 使用内存扫描工具从微信进程中自动提取(待实现)
## 🤖 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
# 克隆项目
git clone https://github.com/your-username/WechatExplorer.git
# 进入目录
cd WechatExplorer
# 安装依赖
pnpm install
# 任选一种方式
WXE_TRAY=1 open /Applications/WechatExplorer.app
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
```
### 配置
启用后:
- macOS dock 图标自动隐藏
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
- 主窗口关闭后 API 服务继续运行
复制 `.env.example` 文件为 `.env`,并填写相关配置(可选,也可以在应用内设置):
### 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)。
### 让 Claude 自动总结你的群聊
复制 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md) 到 `~/.claude/skills/`,然后在 Claude Desktop 里说:
> "今天 技术交流群 聊了啥?"
Claude 会自动:
1.`current_time` 拿到今天日期
2.`chatroom` 找到目标群
3.`chatlog` 拿 JSON 聊天记录
4. 自己用 LLM 生成总结报告
### curl 示例
```bash
cp .env.example .env
```
# 健康检查
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)"
```bash
pnpm dev
```
### 打包构建
```bash
# Windows
pnpm build:win
# macOS
pnpm build:mac
# Linux
pnpm build:linux
# 把群昵称解析成 md5
curl -G "http://127.0.0.1:6131/api/v1/resolve" \
--data-urlencode "q=摸鱼交流群"
```
## ⚠️ 免责声明
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
## 📄 许可证
[MIT](./LICENSE) License
## 🔗 参考
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
- [WeFlow](https://github.com/hicccc77/WeFlow)
- [chatlog](https://github.com/sjzar/chatlog)
+16
View File
@@ -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 已经推送到远程仓库!"
+355
View File
@@ -0,0 +1,355 @@
---
name: wechatexplorer-reader
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app,而非 chatlog/WeFlow。
---
# 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 支持情况调整)
+19 -9
View File
@@ -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,6 +15,11 @@ extraMetadata:
main: out/main/index.js
asarUnpack:
- resources/**
extraResources:
- from: resources
to: resources
filter:
- '**/*'
win:
executableName: wechatexplorer
nsis:
@@ -24,13 +30,17 @@ nsis:
mac:
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:
target:
- AppImage
@@ -42,5 +52,5 @@ appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
provider: github
releaseType: draft
+19 -9
View File
@@ -1,8 +1,14 @@
{
"name": "wechatexplorer",
"version": "1.0.0",
"version": "2.0.1",
"description": "mac 版本获取微信聊天记录, AI群聊总结助手",
"keywords": ["wechat", "chat", "mac微信", "微信聊天记录", "AI群聊总结助手"],
"keywords": [
"wechat",
"chat",
"mac微信",
"微信聊天记录",
"AI群聊总结助手"
],
"author": "Qingmao",
"main": "./out/main/index.js",
"scripts": {
@@ -15,23 +21,27 @@
"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": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
"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": "^2.9.0",
"openai": "^6.10.0",
"silk-wasm": "^3.7.1"
},
"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",
+37 -130
View File
@@ -10,12 +10,12 @@ specifiers:
'@electron-toolkit/preload': ^3.0.2
'@electron-toolkit/tsconfig': ^2.0.0
'@electron-toolkit/utils': ^4.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
better-sqlite3-multiple-ciphers: ^12.5.0
electron: ^39.2.6
electron-builder: ^26.0.12
electron-vite: ^5.0.0
@@ -24,26 +24,30 @@ specifiers:
eslint-plugin-react-hooks: ^7.0.1
eslint-plugin-react-refresh: ^0.4.24
fs-extra: ^11.3.2
html-to-image: ^1.11.13
fzstd: ^0.1.1
koffi: ^2.9.0
openai: ^6.10.0
prettier: ^3.7.4
react: ^19.2.1
react-dom: ^19.2.1
silk-wasm: ^3.7.1
typescript: ^5.9.3
vite: ^7.2.6
dependencies:
'@electron-toolkit/preload': 3.0.2_electron@39.2.6
'@electron-toolkit/utils': 4.0.0_electron@39.2.6
better-sqlite3-multiple-ciphers: 12.5.0
fs-extra: 11.3.2
html-to-image: 1.11.13
fzstd: 0.1.1
koffi: 2.16.2
openai: 6.10.0
silk-wasm: 3.7.1
devDependencies:
'@electron-toolkit/eslint-config-prettier': 3.0.0_fiitszekoa4sqtbwyewxi6kyy4
'@electron-toolkit/eslint-config-ts': 3.1.0_ohw7ybem3stexzc2ktgcr2xgzi
'@electron-toolkit/tsconfig': 2.0.0_@types+node@22.19.2
'@rollup/rollup-darwin-arm64': 4.62.2
'@types/fs-extra': 11.0.4
'@types/node': 22.19.2
'@types/react': 19.2.7
@@ -854,6 +858,12 @@ packages:
dev: true
optional: true
/@rollup/rollup-darwin-arm64/4.62.2:
resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
cpu: [arm64]
os: [darwin]
dev: true
/@rollup/rollup-darwin-x64/4.53.3:
resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==}
cpu: [x64]
@@ -1566,32 +1576,20 @@ packages:
/base64-js/1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/baseline-browser-mapping/2.9.5:
resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==}
hasBin: true
dev: true
/better-sqlite3-multiple-ciphers/12.5.0:
resolution: {integrity: sha512-bYbiV0TdCIQFqlI122+wIFnc3yXWHyz8A/026Y/YfiA3vKcC0wLnHqqociNehpFmbRSowuY3Ko8SyUhBXI9+4A==}
engines: {node: 20.x || 22.x || 23.x || 24.x}
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
dev: false
/bindings/1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
dependencies:
file-uri-to-path: 1.0.0
dev: false
/bl/4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
dev: true
/boolean/3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
@@ -1635,6 +1633,7 @@ packages:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
/builder-util-runtime/9.3.1:
resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==}
@@ -1760,10 +1759,6 @@ packages:
supports-color: 7.2.0
dev: true
/chownr/1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
dev: false
/chownr/2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
@@ -1935,11 +1930,6 @@ packages:
dependencies:
mimic-response: 3.1.0
/deep-extend/0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
dev: false
/deep-is/0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
@@ -1978,6 +1968,7 @@ packages:
/detect-libc/2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dev: true
/detect-node/2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
@@ -2516,11 +2507,6 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/expand-template/2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
dev: false
/exponential-backoff/3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
dev: true
@@ -2584,10 +2570,6 @@ packages:
flat-cache: 4.0.1
dev: true
/file-uri-to-path/1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
dev: false
/filelist/1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
dependencies:
@@ -2640,10 +2622,6 @@ packages:
mime-types: 2.1.35
dev: true
/fs-constants/1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
dev: false
/fs-extra/10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -2717,6 +2695,10 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
/fzstd/0.1.1:
resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==}
dev: false
/generator-function/2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -2771,10 +2753,6 @@ packages:
get-intrinsic: 1.3.0
dev: true
/github-from-package/0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
dev: false
/glob-parent/6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -2928,10 +2906,6 @@ packages:
lru-cache: 6.0.0
dev: true
/html-to-image/1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
dev: false
/http-cache-semantics/4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -3008,6 +2982,7 @@ packages:
/ieee754/1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
dev: true
/ignore/5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
@@ -3051,10 +3026,7 @@ packages:
/inherits/2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
/ini/1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
dev: false
dev: true
/internal-slot/1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
@@ -3387,6 +3359,10 @@ packages:
dependencies:
json-buffer: 3.0.1
/koffi/2.16.2:
resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==}
dev: false
/lazy-val/1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
dev: true
@@ -3558,6 +3534,7 @@ packages:
/minimist/1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
dev: true
/minipass-collect/1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
@@ -3623,10 +3600,6 @@ packages:
yallist: 4.0.0
dev: true
/mkdirp-classic/0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
dev: false
/mkdirp/1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@@ -3642,10 +3615,6 @@ packages:
hasBin: true
dev: true
/napi-build-utils/2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
dev: false
/natural-compare/1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true
@@ -3660,6 +3629,7 @@ packages:
engines: {node: '>=10'}
dependencies:
semver: 7.7.3
dev: true
/node-addon-api/1.7.2:
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
@@ -3908,25 +3878,6 @@ packages:
source-map-js: 1.2.1
dev: true
/prebuild-install/7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.85.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
dev: false
/prelude-ls/1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3994,16 +3945,6 @@ packages:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
/rc/1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
dev: false
/react-dom/19.2.1_react@19.2.1:
resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==}
peerDependencies:
@@ -4043,6 +3984,7 @@ packages:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
dev: true
/reflect.getprototypeof/1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
@@ -4182,6 +4124,7 @@ packages:
/safe-buffer/5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
dev: true
/safe-push-apply/1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
@@ -4335,16 +4278,9 @@ packages:
engines: {node: '>=14'}
dev: true
/simple-concat/1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
dev: false
/simple-get/4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
/silk-wasm/3.7.1:
resolution: {integrity: sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==}
engines: {node: '>=16.11.0'}
dev: false
/simple-update-notifier/2.0.0:
@@ -4509,6 +4445,7 @@ packages:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
safe-buffer: 5.2.1
dev: true
/strip-ansi/6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
@@ -4524,11 +4461,6 @@ packages:
ansi-regex: 6.2.2
dev: true
/strip-json-comments/2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
dev: false
/strip-json-comments/3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -4561,26 +4493,6 @@ packages:
'@pkgr/core': 0.2.9
dev: true
/tar-fs/2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
dev: false
/tar-stream/2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
dev: false
/tar/6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
@@ -4640,12 +4552,6 @@ packages:
typescript: 5.9.3
dev: true
/tunnel-agent/0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
dependencies:
safe-buffer: 5.2.1
dev: false
/type-check/0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4784,6 +4690,7 @@ packages:
/util-deprecate/1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
/verror/1.10.1:
resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+781
View File
@@ -0,0 +1,781 @@
<!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;
}
.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 {{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>
BIN
View File
Binary file not shown.
+54
View File
@@ -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)
}
}
+409
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
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()
})
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DEEPSEEK_API_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+63
View File
@@ -0,0 +1,63 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
export interface StoredKeyResult {
success: boolean
key?: string
error?: string
}
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 load(): Promise<StoredKeyResult> {
try {
if (!(await fs.pathExists(this.filePath))) return { success: true }
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
const encrypted = await fs.readFile(this.filePath)
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
if (!isValidDatabaseKey(key)) return { success: false, error: '已保存的密钥格式无效' }
return { success: true, key }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async save(rawKey: string): Promise<StoredKeyResult> {
const key = normalizeDatabaseKey(rawKey)
if (!isValidDatabaseKey(key)) {
return { success: false, error: '密钥必须是 64 位十六进制字符' }
}
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
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 }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
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) }
}
}
}
+546
View File
@@ -0,0 +1,546 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportMetadata,
ReportHeat
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
const TEMPLATE_NAME = 'mobile_daily_report.html'
const escapeHtml = (value: unknown): string =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
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)
}
}
const templatePath = (): string => {
const candidates = [
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
]
const found = candidates.find((candidate) => fs.existsSync(candidate))
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
return found
}
/**
* 从群成员快照反推真头像,填进 metadata.avatars。
* - 没传 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 modeLabel = (mode: GroupReportMetadata['reportMode']): string =>
mode === 'full' ? '完整版' : '精简版'
const sectionMeta = (
request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta>
) => 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
? `<div class="topic-inline-image"><img src="${topic.image.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('')
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('')
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(), '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),
REPORT_MODE_LABEL: escapeHtml(modeLabel(metadata.reportMode)),
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'),
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)
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
return html
}
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
const reportWindow = new BrowserWindow({
show: false,
width: 430,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
})
try {
await reportWindow.loadFile(htmlPath)
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 });
}))
])`)
reportWindow.webContents.debugger.attach('1.3')
const metrics = (await reportWindow.webContents.debugger.sendCommand(
'Page.getLayoutMetrics'
)) as { cssContentSize: { width: number; height: number } }
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
const height = Math.ceil(metrics.cssContentSize.height)
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
'Page.captureScreenshot',
{
format: 'png',
captureBeyondViewport: true,
fromSurface: true,
clip: { x: 0, y: 0, width, height, scale: 1 }
}
)) as { data: string }
const png = Buffer.from(screenshot.data, 'base64')
if (png.length < 1000) throw new Error('生成的日报图片为空')
await fs.writeFile(pngPath, png)
return `data:image/png;base64,${screenshot.data}`
} finally {
if (reportWindow.webContents.debugger.isAttached()) {
reportWindow.webContents.debugger.detach()
}
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 baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${request.metadata.reportMode === 'full' ? '完整版' : '精简版'}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
return {
success: true,
htmlPath,
pngPath,
imageDataUrl,
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) }
}
}
+382
View File
@@ -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 }
}
}
+512
View File
@@ -0,0 +1,512 @@
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): string | null {
const accountDir = this.getAccountDir()
if (!accountDir) return null
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
console.log('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5,
imageDatName: normalizedDatName,
accountDir
})
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)) {
console.log('[ImageDecrypt] hardlink hit:', fullPath)
return this.getPreferredDatVariantPath(fullPath, true)
}
}
// 尝试 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)
if (directHit) return directHit
}
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0])
if (legacyHit) return legacyHit
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null
}
private fastProbabilisticSearch(attachDir: string, datName: string): 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 = candidates.find((candidate) => existsSync(candidate))
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 = variants
.map((variant) => join(imgDir, variant))
.find((candidate) => existsSync(candidate))
if (found) {
console.log('[ImageDecrypt] found at:', found)
return found
}
}
}
}
} catch (e) {
console.log('[ImageDecrypt]遍历目录失败:', e)
}
return null
}
private findImageFileInLegacyDirs(accountDir: string, datName: string): 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)
if (found) return found
}
return null
}
private recursiveFindDat(dir: string, datName: string, depth: number): string | null {
if (depth < 0) return null
try {
const variants = new Set(this.buildPreferredDatNames(datName))
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)
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 文件版本
*/
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}_h.dat`,
`${base}.dat`,
`${base}_hd.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))
for (const variant of ordered) {
const candidate = join(actualDir, variant)
if (existsSync(candidate)) return candidate
}
return inputPath
}
private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
}
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
}
}
+275 -120
View File
@@ -1,16 +1,52 @@
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard } from 'electron'
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
import { join } from 'path'
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 { 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 { GroupReportExportRequest } from '../shared/group-report'
import { DatabaseKeyStore } from './database-key-store'
import { KeyServiceMac } from './key-service-mac'
import * as chat from './services/chat-service'
import {
apiServer
} from './http-server'
import {
loadSettings,
saveSettings,
getSettingsPath,
AppSettings
} from './services/settings-store'
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()
let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore()
const keyServiceMac = new KeyServiceMac()
let tray: Tray | 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'
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
// In dev, matching the host app name avoids failing the native protection gate.
app.setName('WechatExplorer')
function createWindow(): void {
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
width: 1200,
height: 670,
width: 1400,
height: 800,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
@@ -40,7 +76,8 @@ function createWindow(): void {
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
// 某些 API 只能在此事件发生后使用
app.whenReady().then(() => {
app.whenReady().then(async () => {
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
// 为窗口设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.electron')
@@ -56,149 +93,84 @@ app.whenReady().then(() => {
ipcMain.handle('db:init', (_, key: string) => {
try {
wechatDb = new WechatDb(key)
return true
const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const nextWechatDb = new WechatDb(key)
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 false
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('db:getContacts', (_, filter?: string) => {
if (!wechatDb) return []
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
const contacts: Contact[] = []
const groupContacts = wechatDb.getAllGroupContacts()
const userList = wechatDb.getUserList(filter)
const existingMd5s = new Set<string>()
// 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'
})
}
// 2. 处理聊天表
const chatTables = wechatDb.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
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'
})
}
}
}
return contacts
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(clipboardKey)
})
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => {
if (!wechatDb) return []
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
const groupMembers = wechatDb.getAllGroupMembers()
ipcMain.handle('key:saveDbKey', async (_, key: string) => databaseKeyStore.save(String(key || '')))
return rawMessages.map((msg: WechatMessage) => {
const typeDict: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
10000: '系统消息'
}
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
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: typeDict[msgType] || msg.messageType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content: content,
isSender: msg.mesDes === '0',
img: img,
name: name
// 实际上让我们坚持我们观察到的:1=用户(其他人), 0=助手(我)
}
ipcMain.handle('key:autoGetDbKey', async (event) => {
const result = await keyServiceMac.autoGetDbKey((message) => {
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
})
if (!result.success || !result.key) return result
const saved = await databaseKeyStore.save(result.key)
return {
...result,
saved: saved.success,
warning: saved.success ? undefined : saved.error
}
})
ipcMain.handle('db:search', (_, keyword: string) => {
if (!wechatDb) return null
return wechatDb.searchAllMessages(keyword)
})
ipcMain.handle('db:getContacts', (_, filter?: string) => chat.listContacts(filter))
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) =>
chat.listMessages(userMd5, startTime, endTime)
)
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?: { apiKey?: string; model?: string }
options?: { apiKey?: string; model?: string; baseURL?: string }
) => {
// @ts-ignore: vite env
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
const model = options?.model || 'deepseek-chat'
const model = options?.model || import.meta.env.VITE_AI_MODEL || 'deepseek-v4-flash'
const baseURL =
options?.baseURL || import.meta.env.VITE_AI_BASE_URL || 'https://api.deepseek.com'
if (!apiKey) {
return { success: false, error: '未配置 DeepSeek API Key' }
return { success: false, error: '未配置 API Key' }
}
// 动态导入以避免如果未安装或初始类型缺失的问题
const { OpenAI } = await import('openai')
const openai = new OpenAI({
baseURL: 'https://api.deepseek.com',
apiKey: apiKey
baseURL,
apiKey
})
try {
const completion = await openai.chat.completions.create({
@@ -208,7 +180,7 @@ app.whenReady().then(() => {
})
return { success: true, data: completion.choices[0].message.content }
} catch (error: unknown) {
console.error('DeepSeek API Error:', error)
console.error('AI API Error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: errorMessage }
}
@@ -225,8 +197,133 @@ app.whenReady().then(() => {
}
})
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request)
})
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) => {
void _sessionId
if (!imageDecryptService) {
// 从环境变量获取密钥
const xorKey = import.meta.env.VITE_IMAGE_XOR_KEY || '0x40'
const aesKey = import.meta.env.VITE_IMAGE_AES_KEY || ''
if (!aesKey) {
return { success: false, error: '未配置图片解密密钥' }
}
imageDecryptService = new ImageDecryptService(xorKey, aesKey, chat.getChatDb()?.getWcdb4Client())
}
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const filePath = imageDecryptService.findImageFile(imageMd5, imageDatName)
if (!filePath) {
return { success: false, error: '未找到图片文件' }
}
const base64 = imageDecryptService.decryptImageToBase64(filePath)
if (!base64) {
return { success: false, error: '图片解密失败' }
}
return { success: true, data: base64 }
}
)
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: loadSettings(),
settingsPath: getSettingsPath()
}))
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
const merged = saveSettings({ ...loadSettings(), ...patch })
return { settings: merged, settingsPath: getSettingsPath() }
})
ipcMain.handle('settings:getSelf', () => {
const info = chat.getSelfAccountInfo()
if (!info) return { ready: false }
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('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()
})
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 图标且没有其他窗口打开时,
// 通常会在应用程序中重新创建一个窗口。
@@ -238,7 +335,65 @@ app.whenReady().then(() => {
// 应用程序及其菜单栏通常会保持活动状态,直到用户
// 显式使用 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(join(__dirname, '../../resources/icon.png'))
tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image)
tray.setToolTip('WechatExplorer')
tray.setContextMenu(buildTrayMenu())
tray.on('click', () => showMainWindow())
} catch (error) {
console.warn('[Tray] Failed to create tray:', error)
}
}
+178
View File
@@ -0,0 +1,178 @@
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'
const execFileAsync = promisify(execFile)
export interface DatabaseKeyResult {
success: boolean
key?: string
error?: string
code?: string
}
export class KeyServiceMac {
private getHelperPath(): string {
// 多 candidate fallback:覆盖 extraResources、asarUnpack、dev 三种场景
// (extraResources → Contents/Resources/resources/;asarUnpack 同路径;dev → cwd 或 app.getAppPath)
const candidates = [
// 1) extraResources 标准位置(electron-builder.yml 配的就是这个)
path.join(process.resourcesPath, 'resources', 'xkey_helper'),
// 2) process.resourcesPath 直接(防止 extraResources 没复制成功)
path.join(process.resourcesPath, 'xkey_helper'),
// 3) asarUnpack 路径(如果在 asar 内的 resources/ 被解包到 app.asar.unpacked)
path.join(app.getAppPath(), 'app.asar.unpacked', 'resources', 'xkey_helper'),
// 4) dev 模式 + 打包后某些版本 app.getAppPath() 也指向 .app 根目录
path.join(app.getAppPath(), 'resources', 'xkey_helper'),
// 5) dev 模式:cwd
path.join(process.cwd(), 'resources', 'xkey_helper')
].filter((p, idx, arr) => arr.indexOf(p) === idx) // 去重
// 诊断:即使命中也打 log,方便排查"装了但找不到"的问题(translocation / quarantine)
const statusList = candidates.map((candidate) => ({
path: candidate,
exists: fs.existsSync(candidate)
}))
console.log('[KeyServiceMac] xkey_helper candidates:', JSON.stringify(statusList))
const helperPath = candidates.find((candidate) => fs.existsSync(candidate))
if (!helperPath) {
throw new Error(
`找不到 xkey_helper(尝试 ${candidates.length} 个路径;` +
` app.isPackaged=${app.isPackaged} resourcesPath=${process.resourcesPath} ` +
` appPath=${app.getAppPath()} cwd=${process.cwd()})`
)
}
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) }
}
}
}
+588
View File
@@ -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(/&amp;/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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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
}
+25
View File
@@ -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
}
+393
View File
@@ -0,0 +1,393 @@
import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from '../message-parser'
export function getCurrentKey(): string {
if (!dbRef) return ''
try {
return dbRef.getWcdb4Client().getKey()
} 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
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; 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 listMessages(
userMd5: string,
startTime?: number,
endTime?: number
): FormattedMessage[] {
if (!dbRef) return []
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime)
const groupMembers = dbRef.getGroupMembersForChat(userMd5)
const myAvatar = dbRef.getMyAvatarUrl()
const myGroupNickname = username?.endsWith('@chatroom')
? wcdb4Client.getMyGroupNickname(username)
: undefined
return 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 = ''
if (isMine) {
if (myAvatar) img = myAvatar
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
} else {
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
}
if (content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialWxid = content.substring(0, colonIndex)
if (potentialWxid.startsWith('wxid_')) {
const member = dbRef!.getGroupMember(potentialWxid)
if (member) img = member.m_nsHeadImgUrl
if (groupMembers[potentialWxid]) {
name = groupMembers[potentialWxid]
content = content.substring(colonIndex + 1)
}
}
}
}
let contentData: ReturnType<typeof parseMessageContent> | undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;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,
sessionId: username,
localId,
createTime,
contentData
}
})
}
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 || '',
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
): { success: boolean; error?: string; accountRoot?: string; wxid?: string } {
try {
const probeKey = key.replace(/^0x/i, '').trim()
if (!probeKey) {
return { success: false, error: '密钥不能为空' }
}
const probe = accountRoot ? new WechatDb(probeKey, accountRoot) : new WechatDb(probeKey)
try {
probe.close()
} catch {
// best effort
}
return {
success: true,
accountRoot: probe.getWcdb4Client().getAccountRoot(),
wxid: (probe.getWcdb4Client().getMyUsernameCandidates?.() ?? [])[0] || ''
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(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
}
}
+76
View File
@@ -0,0 +1,76 @@
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
}
const DEFAULT_SETTINGS: AppSettings = {
dbRoot: path.join(
os.homedir(),
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
),
apiEnabled: true,
apiHost: '127.0.0.1',
apiPort: 6131
}
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 }
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
}
+210
View File
@@ -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
}
}
+162
View File
@@ -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])
}
}
File diff suppressed because it is too large Load Diff
+84 -294
View File
@@ -1,19 +1,14 @@
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 } from './wcdb4-client'
export interface UserContact {
m_nsUsrName: string
nickname: string
avatar?: string
}
export interface WechatMessage {
mesLocalID: string
mesDes: string
mesDes: number
messageType: string
msgCreateTime: string
msgContent: string
@@ -26,6 +21,7 @@ export interface Contact {
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface GroupMemberInfo {
@@ -35,328 +31,122 @@ 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 {
// 1. 路径预检查
const targetPath = path.resolve(dbPath)
if (!fs.existsSync(targetPath)) {
console.error(`❌ 错误:数据库文件不存在于路径:${targetPath}`)
return null
}
try {
// console.log(`🔌 正在尝试连接至:${path.basename(targetPath)}`)
// console.log('路径是', targetPath)
// 2. 初始化数据库句柄
// 关键:使用 better-sqlite3-multiple-ciphers 的构造函数
// verbose 选项有助于调试 Pragma 的执行情况
const db = new DatabaseConstructor(targetPath, {
// verbose: console.log
})
// 此时 db 实例已创建,但因为未提供密钥,任何数据读取都会失败。
// 必须立即配置加密参数。
// 3. 配置加密方案 (Cipher Scheme)
// 必须显式切换到 'sqlcipher',因为该库默认可能使用 'chacha20'
db.pragma("cipher='sqlcipher'")
// 4. 配置页大小 (Page Size)
// macOS 版 WeChat 使用 1024 字节。这必须在设置密钥前(或紧随其后)生效。
db.pragma('cipher_page_size = 1024')
// 5. 配置兼容性与算法细节
// 显式指定 SQLCipher 3 的行为
db.pragma('legacy=3')
db.pragma('kdf_iter = 64000')
db.pragma('cipher_hmac_algorithm = HMAC_SHA1')
db.pragma('cipher_kdf_algorithm = PBKDF2_HMAC_SHA1')
// 6. 应用密钥
// 使用正确的 hex 语法: key = "x'HEXSTRING'"
const processedKey = this.processRawKey(this.rawKey)
// console.log(`🔑 应用密钥 (前6位): ${processedKey.substring(0, 6)}...`)
db.pragma(`key = "x'${processedKey}'"`)
// 7. 验证连接
// 如果参数错误,这里将抛出 "file is not a database" 或 "HMAC check failed"
db.prepare('SELECT count(*) as count FROM sqlite_master').get()
// console.log(`✅ 连接成功!当前数据库 ${path.basename(targetPath)} 包含 ${row.count} 张表。`)
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。'
)
}
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) {
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
const client = new Wcdb4Client(rawKey, accountRoot)
client.open()
this.wcdb4Client = client
for (const table of 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 {}
// 1. 检查缓存
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()
// 2. 写入缓存
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 getMyAvatarUrl(): string | undefined {
return this.wcdb4Client.getMyAvatarUrl()
}
public getWcdb4Client(): Wcdb4Client {
return this.wcdb4Client
}
public close(): void {
this.wcdb4Client.close()
}
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
if (!this.chatDb || !this.correctUserId) return []
const tableName = `Chat_${userMd5}`
const tableInfo = this.chatDb.find((t) => t.name === tableName)
if (!tableInfo) {
console.error(`No table found for ${tableName}`)
return []
}
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 []
}
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
return this.wcdb4Client.getMessages(username, startTime, endTime).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)
}
}
+131 -2
View File
@@ -1,19 +1,148 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types'
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
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 }>
getContacts: (filter?: string) => Promise<Contact[]>
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
getGroupSnapshot: (userMd5: string) => Promise<{
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
} | null>
search: (keyword: string) => Promise<string | null>
aiChat: (
messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string }
options?: { apiKey?: string; model?: string; baseURL?: string }
) => Promise<{ success: boolean; data?: string; error?: string }>
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
) => Promise<{ success: boolean; data?: string; error?: string }>
getSticker: (
cdnUrl?: string,
md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
autoGetDbKey: () => Promise<{
success: boolean
key?: string
error?: string
code?: string
saved?: boolean
warning?: string
}>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
saveDbKey: (key: string) => Promise<{ success: boolean; key?: string; error?: string }>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
getSettings: () => Promise<{
settings: {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}
settingsPath: string
}>
setSettings: (patch: Partial<{
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}>) => Promise<{
settings: {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}
settingsPath: string
}>
getSelf: () => Promise<
| {
ready: true
info: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
}
| { ready: false }
>
testConnection: (
key: string,
accountRoot?: string
) => Promise<{
success: boolean
error?: string
accountRoot?: string
wxid?: string
}>
reopenWithRoot: (accountRoot: string) => Promise<{
success: boolean
error?: string
info?: { wxid: string; nickname: string; avatar?: string; accountRoot: 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
}>
}
}
}
+43 -2
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { GroupReportExportRequest } from '../shared/group-report'
// 渲染器的自定义 API
const api = {
@@ -7,12 +8,52 @@ const api = {
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
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 }
options?: { apiKey?: string; model?: string; baseURL?: string }
) => ipcRenderer.invoke('ai:chat', messages, options),
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String)
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) =>
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
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)
},
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),
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)
}
if (process.contextIsolated) {
+1 -1
View File
@@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
/>
</head>
+433 -22
View File
@@ -1,8 +1,95 @@
import React, { useState } from 'react'
import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow'
import { SettingsPanel } from './components/SettingsPanel'
import { Contact, Message } from '../../shared/types'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 250
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (getMessageIdentity(left[index]) !== getMessageIdentity(right[index])) return false
}
return true
}
type GroupSnapshot = {
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
}
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
member.nickname || member.wxid
const buildSyntheticGroupMessages = (
previous: GroupSnapshot | null,
next: GroupSnapshot | null,
referenceMessages: Message[]
): Message[] => {
if (!previous || !next || previous.roomId !== next.roomId) return []
const previousMap = new Map(previous.members.map((member) => [member.wxid, member]))
const nextMap = new Map(next.members.map((member) => [member.wxid, member]))
const latestMessageTime = referenceMessages.reduce(
(max, message) => Math.max(max, message.createTime || 0),
0
)
const fallbackNow = Math.floor(Date.now() / 1000)
const events: Message[] = []
let offset = 1
for (const [wxid, member] of previousMap.entries()) {
if (nextMap.has(wxid)) continue
const name = formatGroupMemberName(member)
const eventTime = Math.max(latestMessageTime + offset, fallbackNow)
const eventDate = new Date(eventTime * 1000)
offset += 1
events.push({
id: `synthetic-leave:${next.roomId}:${wxid}:${eventTime}`,
from: 'system',
type: '系统消息',
datetime: eventDate.toLocaleString('zh-CN', { hour12: false }),
content: `${name} 退出了群聊`,
isSender: false,
createTime: eventTime
})
}
if (events.length) {
console.log(
`[GroupMonitor] synthetic leave detected roomId=${next.roomId} events=${events
.map((event) => event.content)
.join(' | ')}`
)
}
return events
}
const sortMessagesChronologically = (items: Message[]): Message[] =>
[...items].sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
if (timeDelta !== 0) return timeDelta
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
@@ -12,6 +99,102 @@ function App(): React.ReactElement {
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [dateRange, setDateRange] = useState('today') // 默认为今天
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('')
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
const [showDbKey, setShowDbKey] = useState(false)
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const refreshSelfInfo = async (): Promise<void> => {
try {
const result = await window.api.getSelf()
if (result.ready) {
setSelfInfo(result.info)
} else {
setSelfInfo(null)
}
} catch (error) {
console.warn('[SelfInfo] 加载失败:', error)
setSelfInfo(null)
}
}
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
}
React.useEffect(() => {
let active = true
const attemptAutoConnect = async (): Promise<void> => {
// 优先级 1:构建期环境变量 VITE_DB_KEY(本地开发/打包时硬编码的密钥)
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
// 优先级 2:上一次保存到 safeStorage 的密钥
let savedKey = ''
if (!envKey) {
const result = await window.api.getSavedDbKey()
if (result.success && result.key) savedKey = result.key
}
const key = envKey || savedKey
if (!key) {
if (active) setBootState('login')
return
}
if (active) {
setBootState('connecting')
setDbKey(key)
setAutoConnectSource(envKey ? 'env' : 'saved')
setDbKeyStatus(
envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...'
)
setDbKeyStatusKind('normal')
}
try {
const result = await window.api.initDb(key)
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
setDbKeyStatus('已自动连接')
setDbKeyStatusKind('success')
await loadContacts()
void refreshSelfInfo()
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(
`自动连接失败,请重新输入${error ? `: ${error}` : ''}`
)
setDbKeyStatusKind('error')
setBootState('login')
}
} catch (error) {
if (!active) return
const message = error instanceof Error ? error.message : String(error)
setDbKeyStatus(`自动连接失败: ${message}`)
setDbKeyStatusKind('error')
setBootState('login')
}
}
void attemptAutoConnect()
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
if (!active) return
setDbKeyStatus(message)
setDbKeyStatusKind('normal')
})
return () => {
active = false
unsubscribe()
}
}, [])
// useEffect(() => {
// if (import.meta.env.VITE_DB_KEY) {
@@ -23,12 +206,18 @@ function App(): React.ReactElement {
const keyToUse = keyInput || dbKey
if (!keyToUse) return
try {
const success = await window.api.initDb(keyToUse)
const result = await window.api.initDb(keyToUse)
const success = typeof result === 'boolean' ? result : result.success
if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
// 手动输入也持久化,下次启动可自动连接(参考 WeFlow)
void window.api.saveDbKey(keyToUse).catch(() => undefined)
loadContacts()
void refreshSelfInfo()
} else {
alert('Failed to open database. Check your key.')
const error = typeof result === 'boolean' ? '' : result.error
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
}
} catch (error) {
console.error(error)
@@ -36,10 +225,83 @@ function App(): React.ReactElement {
}
}
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
const logGroupSnapshot = React.useCallback(
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
try {
const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as GroupSnapshot | null
if (!snapshot) {
return null
}
return snapshot
} catch (error) {
console.warn(`[GroupSnapshot] reason=${reason} name=${contact.m_nsNickName} failed:`, error)
return null
}
},
[]
)
const mergeSyntheticMessages = React.useCallback(
(contact: Contact | null, baseMessages: Message[], roomId?: string): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const resolvedRoomId = roomId || currentGroupSnapshotRef.current?.roomId
if (!resolvedRoomId) return baseMessages
const synthetic = syntheticGroupMessagesRef.current[resolvedRoomId] || []
return synthetic.length
? sortMessagesChronologically([...baseMessages, ...synthetic])
: baseMessages
},
[]
)
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
setDbKeyStatus('正在准备获取密钥...')
setDbKeyStatusKind('normal')
setShowMacKeyFaq(false)
try {
const result = await window.api.autoGetDbKey()
if (!result.success || !result.key) {
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
throw new Error(result.error || '获取密钥失败')
}
setDbKey(result.key)
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
} catch (error) {
setDbKeyStatus(error instanceof Error ? error.message : String(error))
setDbKeyStatusKind('error')
} finally {
setIsFetchingDbKey(false)
}
}
const handlePasteAndSaveDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.pasteAndSaveDbKey()
if (result.success && result.key) {
setDbKey(result.key)
setDbKeyStatus('已从剪贴板粘贴并安全保存')
setDbKeyStatusKind('success')
} else {
setDbKeyStatus(result.error || '粘贴并保存失败')
setDbKeyStatusKind('error')
}
}
const handleClearSavedDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.clearSavedDbKey()
if (!result.success) {
setDbKeyStatus(result.error || '清除密钥失败')
setDbKeyStatusKind('error')
return
}
setDbKey('')
setDbKeyStatus('已清除保存的密钥')
setDbKeyStatusKind('normal')
}
const getDateRangeParams = (
@@ -78,24 +340,104 @@ function App(): React.ReactElement {
setSelectedContact(contact)
const { startTime, endTime } = getDateRangeParams(dateRange)
const msgs = await window.api.getMessages(contact.md5, startTime, endTime)
setMessages(msgs)
const snapshot = await logGroupSnapshot(contact, 'select-contact')
currentGroupSnapshotRef.current = snapshot
setMessages(mergeSyntheticMessages(contact, msgs, snapshot?.roomId))
}
const handleDateRangeChange = (range: string): void => {
setDateRange(range)
// 如果选择了联系人,则使用新范围重新加载消息
if (selectedContact) {
// 需要调用 handleSelectContact,但它需要一个联系人对象。
// 由于状态更新是异步的,可能需要使用状态中的当前联系人,
// 此函数内部 'selectedContact' 可从闭包中获得。
// 但是,需要确保 'dateRange' 已更新。
// 实际上,就在这里使用新范围手动触发获取。
const { startTime, endTime } = getDateRangeParams(range)
window.api.getMessages(selectedContact.md5, startTime, endTime).then(setMessages)
window.api.getMessages(selectedContact.md5, startTime, endTime).then((nextMessages) => {
setMessages(mergeSyntheticMessages(selectedContact, nextMessages))
})
}
}
React.useEffect(() => {
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
let disposed = false
let refreshTimer: number | null = null
const contactMd5 = selectedContact.md5
const refreshCurrentConversation = async (): Promise<void> => {
try {
const range = getDateRangeParams(dateRange)
const latestMessages = await window.api.getMessages(
contactMd5,
range.startTime,
range.endTime
)
const latestSnapshot = await logGroupSnapshot(selectedContact, 'wcdb-change')
const syntheticEvents = buildSyntheticGroupMessages(
currentGroupSnapshotRef.current,
latestSnapshot,
latestMessages
)
if (latestSnapshot) {
currentGroupSnapshotRef.current = latestSnapshot
if (syntheticEvents.length) {
const existing = syntheticGroupMessagesRef.current[latestSnapshot.roomId] || []
const existingIds = new Set(existing.map((message) => message.id))
const appended = syntheticEvents.filter((message) => !existingIds.has(message.id))
if (appended.length) {
syntheticGroupMessagesRef.current[latestSnapshot.roomId] = [...existing, ...appended]
console.log(
`[GroupMonitor] merged synthetic messages roomId=${latestSnapshot.roomId} total=${syntheticGroupMessagesRef.current[latestSnapshot.roomId].length}`
)
if (!disposed) {
setMessages((current) =>
sortMessagesChronologically([
...current,
...appended.filter(
(message) =>
!current.some((existingMessage) => existingMessage.id === message.id)
)
])
)
}
}
}
}
const nextMessages = mergeSyntheticMessages(
selectedContact,
latestMessages,
latestSnapshot?.roomId
)
if (!disposed) {
setMessages((current) =>
areMessagesEquivalent(current, nextMessages) ? current : nextMessages
)
}
} catch (error) {
console.warn('[MessageMonitor] 刷新当前会话失败:', error)
}
}
const unsubscribe = window.api.onWcdbChange(() => {
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
void refreshCurrentConversation()
}, MESSAGE_MONITOR_DEBOUNCE_MS)
})
return () => {
disposed = true
if (refreshTimer) window.clearTimeout(refreshTimer)
unsubscribe()
}
}, [
dateRange,
isAuthenticated,
isNativeMonitorActive,
selectedContact,
logGroupSnapshot,
mergeSyntheticMessages
])
const handleSearchContacts = (keyword: string): void => {
if (!keyword) {
setFilteredContacts(contacts)
@@ -139,21 +481,75 @@ function App(): React.ReactElement {
}
}, [resize, stopResizing])
if (!isAuthenticated && bootState !== 'login') {
return (
<div className="boot-splash">
<div className="boot-splash-spinner" aria-hidden />
<div className="boot-splash-title">
{bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...'}
</div>
<div className="boot-splash-subtitle">
{bootState === 'connecting'
? autoConnectSource === 'env'
? '检测到环境变量中的密钥'
: '使用上次安全保存的密钥'
: 'WechatExplorer'}
</div>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="login-modal">
<div className="login-box">
<h2>Enter WeChat DB Key</h2>
<input
type="password"
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
/>
<div className="login-input-wrapper">
<input
type={showDbKey ? 'text' : 'password'}
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
/>
<button
type="button"
className="login-input-toggle"
onClick={() => setShowDbKey(!showDbKey)}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
{showDbKey ? '👁️' : '👁️‍🗨️'}
</button>
</div>
<button
className="login-btn login-btn-secondary"
onClick={handleAutoGetDbKey}
disabled={isFetchingDbKey}
>
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
</button>
<button className="login-btn login-btn-secondary" onClick={handlePasteAndSaveDbKey}>
</button>
<button className="login-btn" onClick={() => handleLogin()}>
Connect
</button>
<button className="login-clear-btn" onClick={handleClearSavedDbKey}>
</button>
{dbKeyStatus && (
<div className={`login-key-status ${dbKeyStatusKind}`}>{dbKeyStatus}</div>
)}
{showMacKeyFaq && (
<a
className="login-key-help-link"
href={MAC_KEY_FAQ_URL}
target="_blank"
rel="noreferrer"
>
macOS
</a>
)}
</div>
</div>
)
@@ -170,6 +566,9 @@ function App(): React.ReactElement {
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onOpenSettings={() => setShowSettings(true)}
/>
<div className="resizer" onMouseDown={startResizing} />
<ChatWindow
@@ -180,6 +579,18 @@ function App(): React.ReactElement {
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefreshData={loadContacts}
/>
<SettingsPanel
open={showSettings}
selfInfo={selfInfo}
dbReady={isAuthenticated}
dbKey={dbKey}
onClose={() => setShowSettings(false)}
onDbKeyChange={setDbKey}
onDbRootChanged={() => {
void refreshSelfInfo()
void loadContacts()
}}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
+407 -226
View File
@@ -1,6 +1,14 @@
import React, { useEffect, useRef, useState } from 'react'
import { toPng } from 'html-to-image'
import { Message, Contact } from '../../../shared/types'
import { VoicePlayer } from './VoicePlayer'
import { RichMessageBubble } from './RichMessageBubble'
import { ImageBubble } from './ImageBubble'
import {
buildGroupReportInput,
GROUP_REPORT_SYSTEM_PROMPT,
parseGroupDailyReport
} from '../utils/group-report'
import type { ReportMode } from '../../../shared/group-report'
interface ChatWindowProps {
contact: Contact | null
@@ -10,26 +18,41 @@ interface ChatWindowProps {
onRefreshData?: () => void
}
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称
请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
- 整体评价
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
- 参与者(不超过5个人,将重复的人名去重)
- 注意按时间排序,时间段(从日期几点到几点)
- 过程(50到200字左右)
- 评价(50字以下)
- 生成这7天内热度最高的话题,27日到2日一共7天
需要生成27, 28, 29, 30, 31, 1, 2日的话题总结
- 分割线: ------------
type SummaryDateRange = 'today' | 'yesterday' | '7days'
type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
另外有以下要求:
1. 每个话题结束使用------------分割
2. 使用中文冒号
3. 无需大标题
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨日' },
{ value: '7days', label: '最近 7 天' }
]
最后总结下今日最活跃的前五个发言者`
const SUMMARY_TYPE_OPTIONS: {
value: SummaryMessageType
label: string
messageTypes: string[]
}[] = [
{ value: 'text', label: '文本', messageTypes: ['普通文本'] },
{ value: 'image', label: '图片', messageTypes: ['图片'] },
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
{ value: 'video', label: '视频', messageTypes: ['视频'] },
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
]
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
const endTime = Math.floor(Date.now() / 1000)
if (range === 'yesterday') {
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
}
if (range === '7days') {
return { startTime: startOfToday - 6 * 86400, endTime }
}
return { startTime: startOfToday, endTime }
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
@@ -38,26 +61,53 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onRefresh,
onRefreshData
}) => {
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
)
const messagesEndRef = useRef<HTMLDivElement>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [colWidths, setColWidths] = useState([150, 100, 180, 400])
const [resizingColIndex, setResizingColIndex] = useState<number | null>(null)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
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 imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
)
const [showAvatar, setShowAvatar] = useState(true)
// AI Settings
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [apiKey, setApiKey] = useState(() => localStorage.getItem('deepseek_api_key') || '')
const [model, setModel] = useState('deepseek-chat')
const [apiKey, setApiKey] = useState(() => localStorage.getItem('ai_api_key') || '')
const [baseURL, setBaseURL] = useState(
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
)
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [reportMode, setReportMode] = useState<ReportMode>(
() => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
)
const handleSaveSettings = (): void => {
localStorage.setItem('deepseek_api_key', apiKey)
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
localStorage.setItem('ai_api_key', apiKey)
localStorage.setItem('ai_base_url', baseURL)
localStorage.setItem('ai_model', model)
localStorage.setItem('group_report_mode', reportMode)
setShowSettingsModal(false)
AIChat()
}
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
setSummaryMessageTypes((current) =>
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
)
}
const scrollToBottom = (): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}
@@ -66,32 +116,54 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
scrollToBottom()
}, [messages])
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(0.75)
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(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
}
const resetImageTransform = (): void => {
setImageScale(0.75)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleViewerWheel = (event: React.WheelEvent): void => {
event.preventDefault()
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 => {
@@ -113,11 +185,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
// 昨天
const startOfYesterday = startOfDay - 86400000
return parsed >= startOfYesterday && parsed < startOfDay
} else {
} else if (days === 7) {
// 过去 7 天
const startOf7DaysAgo = startOfDay - 7 * 86400000
return parsed >= startOf7DaysAgo
} else if (days === 30) {
// 过去 30 天
const startOf30DaysAgo = startOfDay - 30 * 86400000
return parsed >= startOf30DaysAgo
}
return true
})
}
@@ -125,7 +202,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const csvContent = [
headers.join(','),
...filtered.map((m) => {
const content = m.content.replace(/"/g, '""').replace(/\n/g, ' ')
let prefix = ''
if (isGroupChat) {
prefix = m.name ? `${m.name}: ` : ''
} else {
const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
prefix = `${name}: `
}
const fullContent = `${prefix}${m.content}`
const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
})
].join('\n')
@@ -140,85 +225,59 @@ 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('当前没有消息可供总结')
if (!contact) return
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
const filteredMessages = messages.filter((msg) => {
delete msg.img
// @ts-ignore 暂时去除
delete msg.from
// @ts-ignore 暂时去除
delete msg.id
// @ts-ignore 暂时去除
delete msg.isSender
return !'分享消息,图片,表情包,视频'.split(',').includes(msg.type)
})
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 { startTime, endTime } = getSummaryDateRange(summaryDateRange)
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
const allowedTypes = new Set(
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
(option) => option.messageTypes
)
)
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
console.log('🚀 ~ AIChat ~ input:', input)
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
const result = await window.api.aiChat(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
{ apiKey, model }
{ apiKey, model, baseURL }
)
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)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
const report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败')
}
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
} catch (error) {
console.error('AI Call Failed:', error)
alert('AI 请求发生错误')
alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
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)
@@ -227,12 +286,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
}
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)
@@ -240,8 +298,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
})
}, [messages, contentFilter])
const visibleMessages = filteredMessages.slice(0, displayLimit)
if (!contact) {
return (
<div className="chat-window">
@@ -257,74 +313,94 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<div className="window-controls"></div>
</div>
<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}
<div className="message-list wechat-message-list">
{filteredMessages.map((msg) => {
const isMine = msg.from === 'assistant'
const isSystem = msg.from === 'system' || msg.type === '系统消息'
const displayName = isMine
? '我'
: isGroupChat
? msg.name || msg.from
: contact.m_nsNickName
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
const isVoice = msg.type === '语音'
const isImage = msg.type === '图片'
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes(
msg.type
)
if (isSystem) {
return (
<div key={msg.id} className="wechat-system-message-row">
<div className="wechat-system-message">{msg.content}</div>
<div className="wechat-system-message-meta">{msg.datetime}</div>
</div>
)
}
return (
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
{!isMine && showAvatar && (
<div className="message-avatar">
{avatarSrc ? (
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
) : (
(displayName || '?').charAt(0)
)}
</div>
)}
<div className="message-stack">
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
<div
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
>
{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' }}>
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 12 }}>
<span>{msg.name}</span>
{msg?.img && (
<img style={{ width: '50px', height: '50px' }} src={msg?.img}></img>
)}
</div>
{/* <span style={{ marginRight: '24px' }}>:</span> */}
<div>{msg.content}</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>
)}
{isVoice && msg.sessionId ? (
<VoicePlayer
sessionId={msg.sessionId}
localId={msg.localId || 0}
createTime={msg.createTime || 0}
/>
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
<ImageBubble
imageMd5={msg.contentData.md5}
imageDatName={msg.contentData.datName}
sessionId={msg.sessionId}
onImageClick={openImagePreview}
/>
) : isRichMedia && msg.contentData ? (
<RichMessageBubble contentData={msg.contentData} />
) : (
<div className="message-text">{msg.content}</div>
)}
</div>
<div className="message-meta">
<span>{msg.datetime}</span>
<span>{msg.type}</span>
</div>
</div>
{isMine && showAvatar && (
<div className="message-avatar mine-avatar">
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
</div>
)}
</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>
@@ -343,47 +419,22 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<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: '16px', color: '#333' }}>...</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
HTML
</div>
</div>
</div>
@@ -393,19 +444,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{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'
}}
>
<div className="report-preview-frame">
<div className="report-preview-scroller">
<img
src={generatedImage}
alt="Generated Summary"
className="report-preview-image"
/>
</div>
</div>
<div className="report-preview-actions">
<button
onClick={handleCopyImage}
style={{
@@ -419,6 +467,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
>
📋
</button>
{reportPaths && (
<button
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
>
</button>
)}
<button
onClick={() => setGeneratedImage(null)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
@@ -430,28 +486,153 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div>
)}
{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
className="image-viewer-stage"
onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown}
onMouseMove={handleViewerMouseMove}
onMouseUp={handleViewerMouseUp}
onMouseLeave={handleViewerMouseUp}
>
<img
src={previewImage}
alt="图片预览"
draggable={false}
style={{
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`
}}
/>
</div>
</div>
</div>
)}
{/* AI Settings Modal */}
{showSettingsModal && (
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
<h3>AI </h3>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
{SUMMARY_DATE_OPTIONS.map((option) => (
<label
key={option.value}
className={summaryDateRange === option.value ? 'selected' : ''}
>
<input
type="radio"
name="summary-date-range"
value={option.value}
checked={summaryDateRange === option.value}
onChange={() => setSummaryDateRange(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
<label className={reportMode === 'compact' ? 'selected' : ''}>
<input
type="radio"
name="report-mode"
value="compact"
checked={reportMode === 'compact'}
onChange={() => setReportMode('compact')}
/>
</label>
<label className={reportMode === 'full' ? 'selected' : ''}>
<input
type="radio"
name="report-mode"
value="full"
checked={reportMode === 'full'}
onChange={() => setReportMode('full')}
/>
</label>
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-type-options">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label key={option.value}>
<input
type="checkbox"
checked={summaryMessageTypes.includes(option.value)}
onChange={() => toggleSummaryMessageType(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>:</label>
<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>
<option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
<option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o Mini</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
<option value="claude-3-5-sonnet-20240620">Claude 3.5 Sonnet</option>
<option value="moonshot-v1-8k">Moonshot V1</option>
</select>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>Base URL:</label>
<input
type="text"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder="https://api.deepseek.com"
style={{ width: '95%', padding: '8px' }}
/>
</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"
placeholder="Enter your API Key"
style={{ width: '95%', padding: '8px' }}
/>
</div>
+121
View File
@@ -0,0 +1,121 @@
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 [error, setError] = useState<string | null>(null)
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) {
// 验证返回的是否是有效的图片 data URL
if (result.data.startsWith('data:image/')) {
setImageUrl(result.data)
setError(null)
} else {
// 解密后不是有效图片格式,显示未解密
setError('未解密')
}
} else {
setError(result.error || '加载图片失败')
}
} catch {
setError('加载图片失败')
}
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) {
await window.api.copyImage(imageUrl)
alert('图片已复制')
}
}
const handleClick = (): void => {
if (imageUrl) {
onImageClick?.(imageUrl)
}
}
if (loading) {
return (
<div className="image-bubble image-loading" onClick={handleClick}>
<div className="image-loading-spinner">...</div>
</div>
)
}
if (error) {
return (
<div className="image-bubble image-error" onClick={loadImage}>
<div className="image-error-text"></div>
</div>
)
}
if (!imageUrl) {
return (
<div ref={containerRef} className="image-bubble image-placeholder">
<div className="image-placeholder-icon">🖼</div>
<div className="image-placeholder-text"></div>
</div>
)
}
return (
<div className="image-bubble image-loaded" onClick={handleClick}>
<img src={imageUrl} alt="图片" className="image-content" />
<div className="image-actions">
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
📋
</button>
</div>
</div>
)
}
@@ -0,0 +1,228 @@
import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
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">{(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">{quotedText}</span>
</div>
{replyText && <div className="quote-reply">{replyText}</div>}
</div>
)
}
function SystemBubble({ data }: { data: Extract<ParsedContent, { type: 'system' }> }): JSX.Element {
return <div className="message-text">{data.content || '[系统消息]'}</div>
}
function getUrlHost(url?: string): string {
if (!url) return ''
try {
return new URL(url).hostname
} catch {
return url
}
}
@@ -0,0 +1,306 @@
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
}
interface ApiState {
running: boolean
host: string
port: number
error?: string
}
interface SettingsPanelProps {
open: boolean
selfInfo: SelfInfo | null
dbReady: boolean
dbKey: string
onClose: () => void
onDbKeyChange: (key: string) => void
onDbRootChanged: () => void
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
open,
selfInfo,
dbReady,
dbKey,
onClose,
onDbKeyChange,
onDbRootChanged
}) => {
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 [busy, setBusy] = useState(false)
useEffect(() => {
if (!open) return
void refresh()
}, [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)
}
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">
macOS Keychain(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?.dbRoot ?? ''}
onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)}
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
placeholder="~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"
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">
xwechat_files , db_storage/
</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>
)
}
+43 -10
View File
@@ -1,6 +1,13 @@
import React, { useState } from 'react'
import { Contact } from '../../../shared/types'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface SidebarProps {
contacts: Contact[]
selectedContact: Contact | null
@@ -10,6 +17,9 @@ interface SidebarProps {
width: number
dateRange: string
onDateRangeChange: (range: string) => void
selfInfo: SelfInfo | null
dbReady: boolean
onOpenSettings: () => void
}
export const Sidebar: React.FC<SidebarProps> = ({
@@ -20,7 +30,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
onContentFilter,
width,
dateRange,
onDateRangeChange
onDateRangeChange,
selfInfo,
dbReady,
onOpenSettings
}) => {
const [searchTerm, setSearchTerm] = useState('')
const [contentFilter, setContentFilter] = useState('')
@@ -48,7 +61,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<div className="contact-avatar">{contact.m_nsNickName.charAt(0)}</div>
<div className="contact-avatar">
{contact.avatar ? (
<img src={contact.avatar} alt={contact.m_nsNickName} referrerPolicy="no-referrer" />
) : (
(contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0)
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.m_nsNickName}</div>
</div>
@@ -98,21 +117,35 @@ export const Sidebar: React.FC<SidebarProps> = ({
<span className="arrow">{isGroupsExpanded ? '▼' : '▶'}</span> ({groups.length})
</div>
{isGroupsExpanded && groups.map(renderContactItem)}
{isGroupsExpanded && groups.length === 0 && <div className="section-empty"></div>}
{/* 联系人部分 */}
<div className="section-header" onClick={() => setIsContactsExpanded(!isContactsExpanded)}>
<span className="arrow">{isContactsExpanded ? '▼' : '▶'}</span> ({users.length})
</div>
{isContactsExpanded && users.map(renderContactItem)}
{isContactsExpanded && users.length === 0 && (
<div className="section-empty"></div>
)}
</div>
<div className="sidebar-footer" onClick={onOpenSettings} title="设置">
<div className="sidebar-self-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
) : (
((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0))
)}
</div>
<div className="sidebar-self-info">
<div className="sidebar-self-nickname">
{dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '我' : '未连接'}
</div>
<div className="sidebar-self-wxid">
{dbReady && selfInfo ? selfInfo.wxid : '点击设置 →'}
</div>
</div>
<div className="sidebar-self-arrow" aria-hidden></div>
</div>
{/* <div className="sidebar-footer">
<div className="sidebar-btn" onClick={() => window.location.reload()}>
<span className="icon">↪️</span> 退出
</div>
<div className="sidebar-status">
✅ 已获得
</div>
</div> */}
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import type { JSX } from 'react'
interface VoicePlayerProps {
sessionId: string
localId: number
createTime: number
svrId?: string | number
duration?: number
}
let globalCurrentAudio: HTMLAudioElement | null = null
let globalStopCallback: (() => void) | null = null
export function VoicePlayer({
sessionId,
localId,
createTime,
svrId
}: VoicePlayerProps): JSX.Element {
const [isPlaying, setIsPlaying] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
if (globalCurrentAudio && globalCurrentAudio !== audio) {
globalCurrentAudio.pause()
globalCurrentAudio.currentTime = 0
globalStopCallback?.()
}
globalCurrentAudio = audio
}, [])
const handlePlayPause = useCallback(async () => {
// 如果还没有音频数据,先获取
if (!audioUrl && !loading) {
setLoading(true)
setShouldAutoPlay(true)
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
try {
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
console.log('[VoicePlayer] got result:', result)
if (result.success && result.data) {
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
const byteCharacters = atob(result.data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'audio/wav' })
const blobUrl = URL.createObjectURL(blob)
console.log('[VoicePlayer] created blob URL:', blobUrl)
setAudioUrl(blobUrl)
} else {
console.log('[VoicePlayer] getVoiceData failed:', result.error)
setError(result.error || '获取语音数据失败')
setShouldAutoPlay(false)
}
} catch (e) {
console.log('[VoicePlayer] exception:', e)
setError('加载语音失败')
setShouldAutoPlay(false)
}
setLoading(false)
return
}
if (!audioRef.current) {
console.log('[VoicePlayer] no audioRef')
return
}
const audio = audioRef.current
if (isPlaying) {
audio.pause()
setIsPlaying(false)
globalStopCallback = null
} else {
stopCurrentAndPlay(audio)
audio
.play()
.then(() => {
console.log('[VoicePlayer] play() succeeded')
})
.catch((e) => {
console.log('[VoicePlayer] play() failed:', e)
})
setIsPlaying(true)
globalStopCallback = () => {
setIsPlaying(false)
audio.currentTime = 0
}
}
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
useEffect(() => {
if (!audioUrl) return
let audio = audioRef.current
if (!audio) {
audio = new Audio(audioUrl)
audioRef.current = audio
}
const audioEl = audio!
audioEl.addEventListener('loadedmetadata', () => {
setAudioDuration(audioEl.duration)
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
})
audioEl.addEventListener('ended', () => {
setIsPlaying(false)
globalStopCallback = null
})
audioEl.addEventListener('timeupdate', () => {
if (audioEl.duration && isFinite(audioEl.duration)) {
setAudioDuration(audioEl.duration)
}
})
audioEl.addEventListener('canplay', () => {
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
if (shouldAutoPlay && audioRef.current) {
setShouldAutoPlay(false)
stopCurrentAndPlay(audioRef.current)
audioRef.current.play()
setIsPlaying(true)
globalStopCallback = () => {
setIsPlaying(false)
if (audioRef.current) {
audioRef.current.currentTime = 0
}
}
}
})
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ''
audioRef.current = null
}
if (globalCurrentAudio === audioRef.current) {
globalCurrentAudio = null
globalStopCallback = null
}
}
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
const formatDuration = (seconds: number | undefined): string => {
if (!seconds || !isFinite(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
if (loading) {
return (
<div className="voice-message voice-loading">
<span className="voice-icon"></span>
<span className="voice-loading-text">...</span>
</div>
)
}
if (error && !audioUrl) {
return (
<div className="voice-message voice-error" onClick={handlePlayPause}>
<span className="voice-icon"></span>
<span className="voice-error-text">[]</span>
</div>
)
}
return (
<div className="voice-message" onClick={handlePlayPause}>
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
<div className="voice-bars" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</div>
<span className="voice-duration">{formatDuration(audioDuration)}</span>
</div>
)
}
@@ -0,0 +1,490 @@
import { Contact, Message } from '../../../shared/types'
import {
GroupDailyReport,
GroupReportMetadata,
ReportFunBadge,
ReportMediaGalleryItem,
ReportMode,
ReportSpeakerRank,
ReportVoiceHighlight,
ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
export interface GroupReportTranscriptRow {
id: string
datetime: string
timestamp: number
sender: string
content: string
avatar?: string
}
export interface GroupReportFactsSnapshot {
metadata: GroupReportMetadata
transcriptRows: GroupReportTranscriptRow[]
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
factsPrompt: string
}
export const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
export const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim()
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
return ownGroupNickname
}
return '我'
}
const candidate = isGroup ? message.name : contact?.m_nsNickName
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
return candidate
}
export const summaryContent = (message: Message): string => {
const data = message.contentData
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
switch (data.type) {
case 'image':
return '[图片]'
case 'sticker':
return '[表情]'
case 'voice':
return `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
case 'quote': {
const reply = data.title || data.content || message.content || '[回复]'
const quotedSender =
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
return `${reply}(引用 ${quotedSender}${data.quotedContent || `[引用${data.quotedType || '消息'}]`}`
}
case 'location':
return `[位置] ${data.poiname || data.label || '位置消息'}`
case 'card':
return `[名片] ${data.nickname || '微信名片'}`
case 'voip':
return `[通话] ${data.status}${data.duration ? `${data.duration}` : ''}`
case 'system':
case 'text':
return data.content
case 'unknown':
return `[${message.type || '未知消息'}]`
}
return `[${message.type || '消息'}]`
}
export const parseTimestamp = (message: Message): number => {
const value = new Date(message.datetime).getTime()
return Number.isFinite(value) ? value : 0
}
export const localDate = (timestamp: number): string => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const localTime = (timestamp: number): string =>
new Date(timestamp).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
export const resolveVoiceDuration = (message: Message): number => {
const fromData = message.contentData?.type === 'voice' ? message.contentData.duration : undefined
return Math.max(0, Number(fromData ?? message.voiceDuration ?? 0) || 0)
}
const truncate = (value: string, max = 48): string =>
value.length > max ? `${value.slice(0, max - 1)}` : value
const buildImageContext = (
messages: Message[],
index: number,
contact: Contact | null,
isGroup: boolean
): {
note: string
stats: string
responseCount: number
participantCount: number
snippets: string[]
} => {
const baseTime = parseTimestamp(messages[index])
const participants = new Set<string>()
const snippets: string[] = []
let responseCount = 0
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
const candidate = messages[offset]
const candidateTime = parseTimestamp(candidate)
if (baseTime && candidateTime && candidateTime - baseTime > 20 * 60 * 1000) break
if (candidate.type === '系统消息' || candidate.from === 'system') continue
const sender = summarySender(candidate, contact, isGroup)
const sameSender = sender === summarySender(messages[index], contact, isGroup)
const content = summaryContent(candidate)
if (!sameSender) {
responseCount += 1
participants.add(sender)
}
if (
snippets.length < 3 &&
!content.startsWith('[图片]') &&
!content.startsWith('[表情]') &&
!content.startsWith('[语音')
) {
snippets.push(truncate(content, 28))
}
}
const note = snippets.length
? `图片发出后,群里接着聊到:${snippets.join(' / ')}`
: responseCount > 0
? '图片发出后引发了一波接续讨论。'
: '这张图片更多像是一次轻量分享,没有形成长链路讨论。'
const statsParts: string[] = []
if (responseCount > 0) statsParts.push(`${responseCount} 条后续消息`)
if (participants.size > 0) statsParts.push(`${participants.size} 人接话`)
if (!statsParts.length) statsParts.push('讨论热度较低')
return {
note,
stats: statsParts.join(' · '),
responseCount,
participantCount: participants.size,
snippets
}
}
const buildMediaSection = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
topSpeakersMap: Map<string, number>
): Promise<{
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
}> => {
const rawImageCandidates = messages
.map((message, index) => {
if (message.contentData?.type !== 'image') return null
const sender = summarySender(message, contact, isGroup)
const context = buildImageContext(messages, index, contact, isGroup)
return {
sourceMessageIds: [message.id],
md5: message.contentData.md5,
datName: message.contentData.datName,
sessionId: message.sessionId,
sender,
time: localTime(parseTimestamp(message)),
note: context.note,
stats: context.stats,
replyCount: context.responseCount,
score: context.responseCount * 3 + context.participantCount * 2 + 1
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => right.score - left.score)
.slice(0, 6)
const imageCandidates = await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
time: item.time,
imageUrl: result.data,
note: item.note,
stats: item.stats,
inferenceLabel: '基于图片后的聊天上下文推断',
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount,
score: item.score
}
})
)
const gallery: ReportMediaGalleryItem[] = imageCandidates
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => right.score - left.score)
.slice(0, 4)
.map(({ score: _score, ...item }) => item)
const voiceMessages = messages
.filter((message) => message.contentData?.type === 'voice')
.map((message) => ({
sender: summarySender(message, contact, isGroup),
duration: resolveVoiceDuration(message),
time: localTime(parseTimestamp(message))
}))
const voiceTotals = new Map<string, { count: number; duration: number }>()
for (const item of voiceMessages) {
const current = voiceTotals.get(item.sender) || { count: 0, duration: 0 }
current.count += 1
current.duration += item.duration
voiceTotals.set(item.sender, current)
}
const voiceLeaderboard: ReportVoiceLeaderboardItem[] = Array.from(voiceTotals.entries())
.map(([sender, value]) => ({
sender,
count: value.count,
durationSec: value.duration
}))
.sort((left, right) => right.durationSec - left.durationSec || right.count - left.count)
.slice(0, 5)
let bestStreak: { sender: string; count: number; duration: number; time: string } | null = null
let currentStreak: { sender: string; count: number; duration: number; time: string } | null = null
for (const message of messages) {
if (message.contentData?.type !== 'voice') {
currentStreak = null
continue
}
const sender = summarySender(message, contact, isGroup)
const duration = resolveVoiceDuration(message)
const time = localTime(parseTimestamp(message))
if (currentStreak && currentStreak.sender === sender) {
currentStreak.count += 1
currentStreak.duration += duration
} else {
currentStreak = { sender, count: 1, duration, time }
}
if (!bestStreak || currentStreak.count > bestStreak.count) {
bestStreak = { ...currentStreak }
}
}
const voiceHighlights: ReportVoiceHighlight[] = []
if (voiceLeaderboard[0]) {
voiceHighlights.push({
title: '语音输出王',
sender: voiceLeaderboard[0].sender,
note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。`
})
}
if (bestStreak && bestStreak.count >= 2) {
voiceHighlights.push({
title: '连续发言时刻',
sender: bestStreak.sender,
note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。`
})
}
const funBadges: ReportFunBadge[] = []
const topSpeaker = Array.from(topSpeakersMap.entries()).sort((left, right) => right[1] - left[1])[0]
if (topSpeaker) {
funBadges.push({
title: '高能输出王',
owner: topSpeaker[0],
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
})
}
if (gallery[0]) {
funBadges.push({
title: '图片话题王',
owner: gallery[0].sender,
note: `${gallery[0].time} 的图片带动了最明显的一轮讨论。`
})
}
if (voiceLeaderboard[0]) {
funBadges.push({
title: '语音麦霸',
owner: voiceLeaderboard[0].sender,
note: `语音总时长暂居第一,适合放进“今日声音档案”。`
})
}
return {
media: {
gallery,
voiceHighlights: voiceHighlights.slice(0, 2),
funBadges: funBadges.slice(0, 3)
},
voiceLeaderboard
}
}
const collectQuestionCandidates = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): string[] =>
messages
.map((message) => ({
id: message.id,
sender: summarySender(message, contact, isGroup),
content: summaryContent(message)
}))
.filter((item) => /[?]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么'))
.slice(-6)
.map((item) => `${item.sender}${item.id}):${truncate(item.content, 32)}`)
const collectReplyFacts = (messages: Message[], contact: Contact | null, isGroup: boolean): string[] =>
messages
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
.slice(0, 10)
.map((message) => {
const sender = summarySender(message, contact, isGroup)
const quotedSender =
message.contentData?.type === 'quote' && message.contentData.quotedSender
? message.contentData.quotedSender
: '群成员'
return `${sender} 回复了 ${quotedSender}`
})
export const buildGroupReportFacts = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
reportMode: ReportMode
): Promise<GroupReportFactsSnapshot> => {
const transcriptRows = messages.map((message) => ({
id: message.id,
datetime: message.datetime,
timestamp: parseTimestamp(message),
sender: summarySender(message, contact, isGroup),
content: summaryContent(message),
avatar: message.img
}))
let firstTimestamp = Number.POSITIVE_INFINITY
let lastTimestamp = Number.NEGATIVE_INFINITY
for (const row of transcriptRows) {
if (!Number.isFinite(row.timestamp)) continue
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
}
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
const speakerCounts = new Map<string, number>()
const hourCounts = new Map<number, number>()
const avatars: Record<string, string | undefined> = {}
let imageCount = 0
let stickerCount = 0
let voiceCount = 0
let voiceDurationSec = 0
for (const message of messages) {
const sender = summarySender(message, contact, isGroup)
const timestamp = parseTimestamp(message)
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
if (Number.isFinite(timestamp)) {
const hour = new Date(timestamp).getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (message.img && !avatars[sender]) avatars[sender] = message.img
if (message.contentData?.type === 'image') imageCount += 1
if (message.contentData?.type === 'sticker') stickerCount += 1
if (message.contentData?.type === 'voice') {
voiceCount += 1
voiceDurationSec += resolveVoiceDuration(message)
}
}
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 5)
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.sort((left, right) => left.hour - right.hour)
.map(
({ hour, count }) =>
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59${count}条)`
)
.join('、')
const startDate = localDate(firstTimestamp)
const endDate = localDate(lastTimestamp)
const sameDay = startDate === endDate
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
const durationHours = durationMs / 3600000
const timeSpan = (() => {
if (sameDay) {
if (durationHours < 1) {
const minutes = Math.max(1, Math.round(durationMs / 60000))
return `${minutes} min`
}
const hours = Math.max(1, Math.ceil(durationHours))
return `${hours} h`
}
const days = Math.max(1, Math.ceil(durationMs / 86400000))
return `${days} d`
})()
const contactName = contact?.m_nsNickName || ''
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
const metadata: GroupReportMetadata = {
groupName,
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
dateRange,
messageCount: transcriptRows.length,
activeUsers: speakerCounts.size,
imageCount,
voiceCount,
stickerCount,
mediaMessageCount: imageCount + voiceCount + stickerCount,
timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于当前已加载的 ${transcriptRows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars,
reportMode
}
const { media, voiceLeaderboard } = await buildMediaSection(messages, contact, isGroup, speakerCounts)
const factsPrompt = [
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
activeTimeline ? `活跃时段:${activeTimeline}` : '',
media.gallery.length
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats}`).join('')}`
: '',
voiceLeaderboard.length
? `语音榜:${voiceLeaderboard
.slice(0, 3)
.map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec}`)
.join('')}`
: '',
collectQuestionCandidates(messages, contact, isGroup).length
? `疑似待跟进问题:${collectQuestionCandidates(messages, contact, isGroup).join('')}`
: '',
collectReplyFacts(messages, contact, isGroup).length
? `回复关系样本:${collectReplyFacts(messages, contact, isGroup).join('')}`
: ''
]
.filter(Boolean)
.join('\n')
return {
metadata,
transcriptRows,
topSpeakers,
activeTimeline,
media,
voiceLeaderboard,
factsPrompt
}
}
+734
View File
@@ -0,0 +1,734 @@
import {
GroupDailyReport,
GroupReportMetadata,
ReportHeat,
ReportImportantMessage,
ReportMode,
ReportParticipantChain,
ReportQuestionAnswer,
ReportQuote,
ReportResource,
ReportReversal,
ReportSectionKey,
ReportSectionMeta,
ReportSpeakerRank,
ReportStoryline,
ReportTodoItem,
ReportTopic,
ReportUnresolvedItem,
ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
import { Contact, Message } from '../../../shared/types'
import { buildGroupReportFacts } from './group-report-facts'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
总原则:
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
3. 图片、表情、语音、视频或链接内容不可见时,只能描述消息类型、互动效果和上下文,不能猜图片具体内容。
4. 先合并同一事件,再按重要性排序,避免一个事件在多个模块大段重复出现。
5. 优先保留结论、待办、未解决问题、明确通知,再保留趣味内容。
6. 负责人、截止日期、图片内容必须有聊天证据;没有证据就返回 null 或省略。
7. 不为了填满模板而生成内容,没有就输出空数组。
8. 所有候选条目尽量返回 sourceMessageIds,便于程序去重和追溯。
9. 精简版面向 30 秒阅读,摘要必须短;完整版可以保留更多候选项。
10. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
JSON 结构必须为:
{
"overview": "1至2句整体讨论风格与氛围",
"hero": {
"headline": "一句抓重点的日报标题",
"summary": "一句总结今天发生了什么,最多3行",
"keyTakeaway": "最重要结论,没有可空",
"pendingNote": "最值得跟进的一项,没有可空",
"statusLine": "例如:今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决"
},
"topics": [{
"title": "话题标题",
"timeRange": "HH:mm-HH:mm",
"heat": "高|中|低",
"participants": ["昵称"],
"summary": "100字以内",
"conclusion": "可选简短结论",
"conclusions": [{"text":"关键结论","sourceMessageIds":["消息ID"]}],
"keywords": ["关键词"],
"sourceMessageIds": ["消息ID"]
}],
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称","sourceMessageIds":["消息ID"]}],
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要","sourceMessageIds":["消息ID"],"importance":0.95,"confidence":0.9}],
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话","sourceMessageId":"消息ID"}],"note":"为什么好笑或值得看","sourceMessageIds":["消息ID"],"importance":0.8,"confidence":0.8}],
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称","sourceMessageIds":["消息ID"]}],
"todos": [{"task":"待办事项","owner":"负责人或null","deadline":"截止时间或null","topic":"来源话题或null","note":"补充说明","sourceMessageIds":["消息ID"],"importance":0.95,"confidence":0.85}],
"unresolved": [{"question":"待跟进的问题","owner":"提问者或相关人","status":"待跟进|暂未回答|进行中","note":"为什么还没结束","lastDiscussedAt":"HH:mm 或 null","sourceMessageIds":["消息ID"],"importance":0.9,"confidence":0.8}],
"storylines": [{"title":"剧情线标题","stages":[{"time":"HH:mm","event":"发生了什么","sourceMessageIds":["消息ID"]}],"result":"结果","sourceMessageIds":["消息ID"]}],
"reversals": [{"topic":"发生反转的话题","initialView":"最初判断","finalView":"最终判断","note":"为什么反转","sourceMessageIds":["消息ID"]}],
"participantChains": [{"topic":"话题名","chain":["A 提出","B 补充","C 收尾"],"note":"链路说明","sourceMessageIds":["消息ID"]}],
"keywords": ["关键词"]
}
额外要求:
- 精简版:热点最多4个、重要消息最多4条、待办最多5条、未解决最多3条、名场面最多2组、每组最多4条、关键词最多12个。
- 完整版:可以保留更多候选项,但仍要去重和排序。
- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
export interface GroupReportInput {
prompt: string
metadata: GroupReportMetadata
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
voiceLeaderboard: ReportVoiceLeaderboardItem[]
media: GroupDailyReport['media']
}
const REPORT_MODE_LABEL: Record<ReportMode, string> = {
compact: '精简版(推荐)',
full: '完整版'
}
interface ReportModeConfig {
maxTopics: number
maxImportantMessages: number
maxTodos: number
maxUnresolved: number
maxQuotes: number
maxQuoteMessages: number
maxKeywords: number
maxStorylines: number
maxReversals: number
maxChains: number
maxGallery: number
maxVoiceHighlights: number
maxBadges: number
maxResources: number
maxQa: number
topicSummaryLength: number
noteLength: number
enabledSections: ReportSectionKey[]
}
const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
compact: {
maxTopics: 3,
maxImportantMessages: 3,
maxTodos: 4,
maxUnresolved: 3,
maxQuotes: 1,
maxQuoteMessages: 4,
maxKeywords: 12,
maxStorylines: 0,
maxReversals: 0,
maxChains: 0,
maxGallery: 0,
maxVoiceHighlights: 0,
maxBadges: 0,
maxResources: 0,
maxQa: 0,
topicSummaryLength: 100,
noteLength: 72,
enabledSections: ['hero', 'topics', 'importantMessages', 'actions', 'moments', 'analytics', 'keywords']
},
full: {
maxTopics: 6,
maxImportantMessages: 6,
maxTodos: 8,
maxUnresolved: 5,
maxQuotes: 3,
maxQuoteMessages: 5,
maxKeywords: 15,
maxStorylines: 2,
maxReversals: 2,
maxChains: 3,
maxGallery: 4,
maxVoiceHighlights: 2,
maxBadges: 3,
maxResources: 4,
maxQa: 4,
topicSummaryLength: 140,
noteLength: 96,
enabledSections: [
'hero',
'topics',
'importantMessages',
'actions',
'moments',
'analytics',
'keywords',
'resources',
'qa',
'storylines',
'reversals',
'gallery',
'voices',
'badges',
'chains'
]
}
}
export const buildGroupReportInput = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
reportMode: ReportMode
): Promise<GroupReportInput> => {
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
const transcript = facts.transcriptRows
.map((row) => `[${row.id}] ${row.datetime} ${row.sender}${row.content}`)
.join('\n')
const prompt = `请为以下微信${isGroup ? '群聊' : '对话'}记录生成日报 JSON。
会话名:${facts.metadata.groupName}
时间范围:${facts.metadata.dateRange}
消息数:${facts.metadata.messageCount}
活跃人数:${facts.metadata.activeUsers}
目标模式:${REPORT_MODE_LABEL[reportMode]}
完整性:仅基于当前应用已加载的记录。
媒体与结构化事实(可引用行为,不可猜测图片内容):
${facts.factsPrompt || '无'}
聊天记录:
${transcript}`
return {
prompt,
metadata: facts.metadata,
topSpeakers: facts.topSpeakers,
activeTimeline: facts.activeTimeline,
voiceLeaderboard: facts.voiceLeaderboard,
media: facts.media
}
}
const asObject = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const asNullableString = (value: unknown): string | null => {
const result = asString(value)
return result || null
}
const asNumber = (value: unknown): number => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
const asStrings = (value: unknown, limit = 20): string[] =>
asArray(value).map(asString).filter(Boolean).slice(0, limit)
const normalizeName = (value: unknown): string => asString(value) || '未命名群成员'
const normalizeHeat = (value: unknown): ReportHeat => {
const heat = asString(value)
if (heat.includes('高')) return '高'
if (heat.includes('低')) return '低'
return '中'
}
const truncate = (value: string, max: number): string =>
value.length > max ? `${value.slice(0, Math.max(1, max - 1))}` : value
const extractJson = (raw: string): unknown => {
const cleaned = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
return JSON.parse(cleaned.slice(start, end + 1))
}
const createSignature = (...parts: Array<string | null | undefined>): string =>
parts
.map((part) => (part || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('|')
.toLowerCase()
const parseTopics = (root: Record<string, unknown>): ReportTopic[] =>
asArray(root.topics)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
timeRange: asString(item.timeRange),
heat: normalizeHeat(item.heat),
participants: asArray(item.participants).map(normalizeName).filter(Boolean).slice(0, 5),
summary: asString(item.summary),
conclusion: asString(item.conclusion),
conclusions: asArray(item.conclusions)
.map((entry) => {
const conclusion = asObject(entry)
return {
text: asString(conclusion.text),
sourceMessageIds: asStrings(conclusion.sourceMessageIds, 6)
}
})
.filter((entry) => entry.text)
.slice(0, 4),
keywords: asStrings(item.keywords, 8),
sourceMessageIds: asStrings(item.sourceMessageIds, 12)
}
})
.filter((topic) => topic.title && topic.summary)
const parseResources = (root: Record<string, unknown>): ReportResource[] =>
asArray(root.resources)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
description: asString(item.description),
sender: item.sender ? normalizeName(item.sender) : undefined,
sourceMessageIds: asStrings(item.sourceMessageIds, 6)
}
})
.filter((item) => item.title && item.description)
const parseImportantMessages = (root: Record<string, unknown>): ReportImportantMessage[] =>
asArray(root.importantMessages)
.map((value) => {
const item = asObject(value)
return {
sender: normalizeName(item.sender),
time: asString(item.time),
content: asString(item.content),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 6),
importance: asNumber(item.importance),
confidence: asNumber(item.confidence)
}
})
.filter((item) => item.sender && item.content)
const parseQuotes = (root: Record<string, unknown>): ReportQuote[] =>
asArray(root.quotes)
.map((value) => {
const item = asObject(value)
return {
messages: asArray(item.messages)
.map((messageValue) => {
const message = asObject(messageValue)
return {
sender: normalizeName(message.sender),
content: asString(message.content),
sourceMessageId: asString(message.sourceMessageId)
}
})
.filter((message) => message.sender && message.content),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
importance: asNumber(item.importance),
confidence: asNumber(item.confidence)
}
})
.filter((quote) => quote.messages.length)
const parseQa = (root: Record<string, unknown>): ReportQuestionAnswer[] =>
asArray(root.qa)
.map((value) => {
const item = asObject(value)
return {
question: asString(item.question),
answer: asString(item.answer),
answerer: item.answerer ? normalizeName(item.answerer) : undefined,
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
}
})
.filter((item) => item.question && item.answer)
const parseTodos = (root: Record<string, unknown>): ReportTodoItem[] =>
asArray(root.todos)
.map((value) => {
const item = asObject(value)
return {
task: asString(item.task),
owner: item.owner === null ? null : item.owner ? normalizeName(item.owner) : undefined,
deadline: item.deadline === null ? null : asNullableString(item.deadline),
topic: item.topic === null ? null : asNullableString(item.topic),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
importance: asNumber(item.importance),
confidence: asNumber(item.confidence)
}
})
.filter((item) => item.task)
const parseUnresolved = (root: Record<string, unknown>): ReportUnresolvedItem[] =>
asArray(root.unresolved)
.map((value) => {
const item = asObject(value)
const status = asString(item.status)
const normalizedStatus: ReportUnresolvedItem['status'] =
status === '暂未回答' || status === '进行中' || status === '待跟进' ? status : '待跟进'
return {
question: asString(item.question),
owner: item.owner ? normalizeName(item.owner) : undefined,
status: normalizedStatus,
note: asString(item.note),
lastDiscussedAt: item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt),
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
importance: asNumber(item.importance),
confidence: asNumber(item.confidence)
}
})
.filter((item) => item.question && item.note)
const parseStorylines = (root: Record<string, unknown>): ReportStoryline[] =>
asArray(root.storylines)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
stages: asArray(item.stages)
.map((stageValue) => {
const stage = asObject(stageValue)
return {
time: asString(stage.time),
event: asString(stage.event),
sourceMessageIds: asStrings(stage.sourceMessageIds, 6)
}
})
.filter((stage) => stage.event),
result: asString(item.result),
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
}
})
.filter((item) => item.title && item.stages.length)
const parseReversals = (root: Record<string, unknown>): ReportReversal[] =>
asArray(root.reversals)
.map((value) => {
const item = asObject(value)
return {
topic: asString(item.topic),
initialView: asString(item.initialView),
finalView: asString(item.finalView),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
}
})
.filter((item) => item.topic && item.initialView && item.finalView)
const parseParticipantChains = (root: Record<string, unknown>): ReportParticipantChain[] =>
asArray(root.participantChains)
.map((value) => {
const item = asObject(value)
return {
topic: asString(item.topic),
chain: asStrings(item.chain, 5),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
}
})
.filter((item) => item.topic && item.chain.length)
const scoreByHeat = (heat: ReportHeat): number => (heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55)
const scoreByCount = (count: number, max = 5): number => Math.min(1, Math.max(0.3, count / max))
const dedupeItems = <T>(
items: T[],
getSources: (item: T) => string[],
getSignature: (item: T) => string
): T[] => {
const seenSources = new Set<string>()
const seenSignatures = new Set<string>()
const result: T[] = []
for (const item of items) {
const signature = getSignature(item)
const sources = getSources(item).filter(Boolean)
const sourceOverlap = sources.some((source) => seenSources.has(source))
if (signature && seenSignatures.has(signature)) continue
if (sourceOverlap && sources.length) continue
result.push(item)
if (signature) seenSignatures.add(signature)
for (const source of sources) seenSources.add(source)
}
return result
}
const sortByScore = <T>(items: T[], scorer: (item: T) => number): T[] =>
[...items].sort((left, right) => scorer(right) - scorer(left))
const buildSectionMeta = (
enabled: boolean,
displayedCount: number,
totalCount: number,
importance: number,
confidence: number
): ReportSectionMeta => ({
enabled: enabled && displayedCount > 0,
importance,
confidence,
totalCount,
displayedCount,
hiddenCount: Math.max(0, totalCount - displayedCount)
})
const clampTopics = (topics: ReportTopic[], config: ReportModeConfig): ReportTopic[] =>
topics.slice(0, config.maxTopics).map((topic) => ({
...topic,
summary: truncate(topic.summary, config.topicSummaryLength),
conclusions: (topic.conclusions || [])
.slice(0, 2)
.map((entry) => ({ ...entry, text: truncate(entry.text, config.noteLength) })),
conclusion: topic.conclusion ? truncate(topic.conclusion, config.noteLength) : topic.conclusion,
keywords: topic.keywords.slice(0, 3)
}))
const attachHighImpactImage = (
topics: ReportTopic[],
gallery: GroupDailyReport['media']['gallery']
): ReportTopic[] => {
if (!gallery.length) return topics
const [firstImage, ...rest] = gallery
const nextTopics = topics.map((topic, index) =>
index === 0 && firstImage.replyCount && firstImage.replyCount >= 3
? {
...topic,
image: {
imageUrl: firstImage.imageUrl,
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
sourceMessageIds: firstImage.sourceMessageIds
}
}
: topic
)
gallery.splice(0, rest.length >= 0 ? 1 : 0)
return nextTopics
}
const postProcessReport = (
report: GroupDailyReport,
mode: ReportMode,
metadata: GroupReportMetadata
): GroupDailyReport => {
const config = REPORT_MODE_CONFIG[mode]
const topicsScored = sortByScore(report.topics, (item) => scoreByHeat(item.heat))
const topicsDeduped = dedupeItems(
topicsScored,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.title, item.summary)
)
let gallery = [...report.media.gallery]
const topics = attachHighImpactImage(clampTopics(topicsDeduped, config), gallery)
const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
Math.max(item.importance || 0, item.confidence || 0.6)
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.55 : true))
const importantMessages = dedupeItems(
importantMessagesRaw,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.content, item.note)
)
.slice(0, config.maxImportantMessages)
.map((item) => ({
...item,
content: truncate(item.content, config.noteLength),
note: truncate(item.note, config.noteLength)
}))
const todosRaw = sortByScore(report.todos, (item) =>
Math.max(item.importance || 0.6, item.confidence || 0.5)
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.5 : true))
const todos = dedupeItems(
todosRaw,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.task, item.owner || '', item.deadline || '')
)
.slice(0, config.maxTodos)
.map((item) => ({
...item,
task: truncate(item.task, config.noteLength),
note: item.note ? truncate(item.note, config.noteLength) : item.note
}))
const unresolvedRaw = sortByScore(report.unresolved, (item) =>
Math.max(item.importance || 0.6, item.confidence || 0.5)
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.45 : true))
const unresolved = dedupeItems(
unresolvedRaw,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.question, item.note)
)
.slice(0, config.maxUnresolved)
.map((item) => ({
...item,
question: truncate(item.question, config.noteLength),
note: truncate(item.note, config.noteLength)
}))
const quotesRaw = sortByScore(report.quotes, (item) =>
Math.max(item.importance || 0.55, item.confidence || 0.55, scoreByCount(item.messages.length, 4))
)
const quotes = dedupeItems(
quotesRaw,
(item) => item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''),
(item) => createSignature(item.note, item.messages.map((message) => message.content).join('|'))
)
.slice(0, config.maxQuotes)
.map((item) => ({
...item,
messages: item.messages.slice(0, config.maxQuoteMessages).map((message) => ({
...message,
content: truncate(message.content, Math.min(config.noteLength, 38))
})),
note: truncate(item.note, config.noteLength)
}))
const resources = report.resources.slice(0, config.maxResources)
const qa = report.qa.slice(0, config.maxQa)
const storylines = report.storylines.slice(0, config.maxStorylines)
const reversals = report.reversals.slice(0, config.maxReversals)
const participantChains = report.participantChains.slice(0, config.maxChains)
const voiceHighlights = report.media.voiceHighlights.slice(0, config.maxVoiceHighlights)
const funBadges = report.media.funBadges.slice(0, config.maxBadges)
const keywords = report.keywords.slice(0, config.maxKeywords)
const conclusionCount =
topics.reduce((count, topic) => count + (topic.conclusions?.length || 0), 0) +
importantMessages.length
const summaryStats = {
messageCount: metadata.messageCount,
activeUsers: metadata.activeUsers,
topicCount: topics.length,
mediaCount: metadata.mediaMessageCount || 0,
imageCount: metadata.imageCount || 0,
voiceCount: metadata.voiceCount || 0,
stickerCount: metadata.stickerCount || 0,
conclusionCount,
todoCount: todos.length,
unresolvedCount: unresolved.length
}
const hero = {
headline:
report.hero?.headline || topics[0]?.title || `${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`,
summary: truncate(
report.hero?.summary || report.overview || '今天群里有新的讨论进展。',
mode === 'compact' ? 84 : 120
),
keyTakeaway: report.hero?.keyTakeaway ? truncate(report.hero.keyTakeaway, config.noteLength) : undefined,
pendingNote: report.hero?.pendingNote ? truncate(report.hero.pendingNote, config.noteLength) : undefined,
statusLine:
report.hero?.statusLine ||
`今日形成 ${summaryStats.conclusionCount} 个结论 · ${summaryStats.todoCount} 个待办 · ${summaryStats.unresolvedCount} 个问题尚未解决`
}
const sectionMeta: Partial<Record<ReportSectionKey, ReportSectionMeta>> = {
hero: buildSectionMeta(true, 1, 1, 1, 0.95),
topics: buildSectionMeta(config.enabledSections.includes('topics'), topics.length, report.topics.length, 0.98, 0.85),
importantMessages: buildSectionMeta(
config.enabledSections.includes('importantMessages'),
importantMessages.length,
report.importantMessages.length,
0.95,
0.82
),
actions: buildSectionMeta(
config.enabledSections.includes('actions'),
todos.length + unresolved.length,
report.todos.length + report.unresolved.length,
0.97,
0.8
),
moments: buildSectionMeta(config.enabledSections.includes('moments'), quotes.length, report.quotes.length, 0.75, 0.72),
analytics: buildSectionMeta(config.enabledSections.includes('analytics'), 1, 1, 0.8, 0.95),
keywords: buildSectionMeta(config.enabledSections.includes('keywords'), keywords.length, report.keywords.length, 0.68, 0.9),
resources: buildSectionMeta(config.enabledSections.includes('resources'), resources.length, report.resources.length, 0.55, 0.75),
qa: buildSectionMeta(config.enabledSections.includes('qa'), qa.length, report.qa.length, 0.62, 0.78),
storylines: buildSectionMeta(config.enabledSections.includes('storylines'), storylines.length, report.storylines.length, 0.63, 0.74),
reversals: buildSectionMeta(config.enabledSections.includes('reversals'), reversals.length, report.reversals.length, 0.54, 0.72),
gallery: buildSectionMeta(config.enabledSections.includes('gallery'), gallery.length, report.media.gallery.length, 0.6, 0.8),
voices: buildSectionMeta(config.enabledSections.includes('voices'), voiceHighlights.length, report.media.voiceHighlights.length, 0.56, 0.84),
badges: buildSectionMeta(config.enabledSections.includes('badges'), funBadges.length, report.media.funBadges.length, 0.45, 0.65),
chains: buildSectionMeta(config.enabledSections.includes('chains'), participantChains.length, report.participantChains.length, 0.58, 0.71)
}
return {
...report,
mode,
hero,
topics,
resources,
importantMessages,
quotes,
qa,
todos,
unresolved,
storylines,
reversals,
participantChains,
keywords,
media: {
gallery,
voiceHighlights,
funBadges
},
summaryStats,
sectionMeta
}
}
export const parseGroupDailyReport = (
raw: string,
topSpeakers: ReportSpeakerRank[],
activeTimeline: string,
voiceLeaderboard: ReportVoiceLeaderboardItem[],
metadata: GroupReportMetadata,
media: GroupDailyReport['media']
): GroupDailyReport => {
const root = asObject(extractJson(raw))
const topics = parseTopics(root)
if (!topics.length) throw new Error('AI 日报中没有有效话题')
const heroRoot = asObject(root.hero)
const report: GroupDailyReport = {
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
mode: metadata.reportMode || 'compact',
hero:
heroRoot && Object.keys(heroRoot).length
? {
headline: asString(heroRoot.headline) || topics[0]?.title || '今日群聊速览',
summary: asString(heroRoot.summary) || asString(root.overview) || '今天群里有新的讨论进展。',
keyTakeaway: asString(heroRoot.keyTakeaway),
pendingNote: asString(heroRoot.pendingNote),
statusLine: asString(heroRoot.statusLine)
}
: undefined,
topics,
resources: parseResources(root),
importantMessages: parseImportantMessages(root),
quotes: parseQuotes(root),
qa: parseQa(root),
todos: parseTodos(root),
unresolved: parseUnresolved(root),
storylines: parseStorylines(root),
reversals: parseReversals(root),
participantChains: parseParticipantChains(root),
analytics: {
topicHeat: topics.map((topic) => ({
topic: topic.title,
score: topic.heat === '高' ? 100 : topic.heat === '中' ? 65 : 35
})),
activeTimeline: activeTimeline || '暂无可用时间统计',
topSpeakers: topSpeakers.map((speaker) => ({
name: speaker.name,
count: Math.max(0, asNumber(speaker.count))
})),
voiceLeaderboard
},
keywords: asStrings(root.keywords, 15),
media
}
return postProcessReport(report, metadata.reportMode || 'compact', metadata)
}
+264
View File
@@ -0,0 +1,264 @@
export type ReportHeat = '高' | '中' | '低'
export type ReportMode = 'compact' | 'full'
export type ReportSectionKey =
| 'hero'
| 'topics'
| 'importantMessages'
| 'actions'
| 'moments'
| 'analytics'
| 'keywords'
| 'resources'
| 'qa'
| 'storylines'
| 'reversals'
| 'gallery'
| 'voices'
| 'badges'
| 'chains'
export interface ReportSectionMeta {
enabled: boolean
importance: number
confidence: number
totalCount: number
displayedCount: number
hiddenCount?: number
}
export interface ReportTopicConclusion {
text: string
sourceMessageIds?: string[]
}
export interface ReportTopic {
title: string
timeRange: string
heat: ReportHeat
participants: string[]
summary: string
conclusion?: string
conclusions?: ReportTopicConclusion[]
keywords: string[]
sourceMessageIds?: string[]
image?: {
imageUrl?: string
note: string
sourceMessageIds?: string[]
} | null
}
export interface ReportResource {
title: string
description: string
sender?: string
sourceMessageIds?: string[]
}
export interface ReportImportantMessage {
sender: string
time: string
content: string
note: string
sourceMessageIds?: string[]
importance?: number
confidence?: number
}
export interface ReportQuoteMessage {
sender: string
content: string
sourceMessageId?: string
}
export interface ReportQuote {
messages: ReportQuoteMessage[]
note: string
sourceMessageIds?: string[]
importance?: number
confidence?: number
}
export interface ReportQuestionAnswer {
question: string
answer: string
answerer?: string
sourceMessageIds?: string[]
}
export interface ReportTopicHeat {
topic: string
score: number
}
export interface ReportSpeakerRank {
name: string
count: number
}
export interface ReportHero {
headline: string
summary: string
keyTakeaway?: string
pendingNote?: string
statusLine?: string
}
export interface ReportMediaGalleryItem {
sender: string
time: string
imageUrl: string
note: string
stats?: string
inferenceLabel?: string
sourceMessageIds?: string[]
replyCount?: number
}
export interface ReportVoiceHighlight {
title: string
sender: string
note: string
sourceMessageIds?: string[]
}
export interface ReportVoiceLeaderboardItem {
sender: string
count: number
durationSec: number
}
export interface ReportFunBadge {
title: string
owner: string
note: string
}
export interface ReportTodoItem {
task: string
owner?: string | null
deadline?: string | null
topic?: string | null
note?: string
sourceMessageIds?: string[]
importance?: number
confidence?: number
}
export interface ReportUnresolvedItem {
question: string
owner?: string
status: '待跟进' | '暂未回答' | '进行中'
note: string
lastDiscussedAt?: string | null
sourceMessageIds?: string[]
importance?: number
confidence?: number
}
export interface ReportStorylineStage {
time: string
event: string
sourceMessageIds?: string[]
}
export interface ReportStoryline {
title: string
stages: ReportStorylineStage[]
result?: string
sourceMessageIds?: string[]
}
export interface ReportReversal {
topic: string
initialView: string
finalView: string
note?: string
sourceMessageIds?: string[]
}
export interface ReportParticipantChain {
topic: string
chain: string[]
note?: string
sourceMessageIds?: string[]
}
export interface ReportSummaryStats {
messageCount: number
activeUsers: number
topicCount: number
mediaCount: number
imageCount: number
voiceCount: number
stickerCount: number
conclusionCount: number
todoCount: number
unresolvedCount: number
}
export interface GroupDailyReport {
overview: string
mode?: ReportMode
hero?: ReportHero
topics: ReportTopic[]
resources: ReportResource[]
importantMessages: ReportImportantMessage[]
quotes: ReportQuote[]
qa: ReportQuestionAnswer[]
todos: ReportTodoItem[]
unresolved: ReportUnresolvedItem[]
storylines: ReportStoryline[]
reversals: ReportReversal[]
participantChains: ReportParticipantChain[]
analytics: {
topicHeat: ReportTopicHeat[]
activeTimeline: string
topSpeakers: ReportSpeakerRank[]
voiceLeaderboard: ReportVoiceLeaderboardItem[]
}
keywords: string[]
media: {
gallery: ReportMediaGalleryItem[]
voiceHighlights: ReportVoiceHighlight[]
funBadges: ReportFunBadge[]
}
summaryStats?: ReportSummaryStats
sectionMeta?: Partial<Record<ReportSectionKey, ReportSectionMeta>>
}
export interface GroupReportMetadata {
groupName: string
reportDate: string
dateRange: string
messageCount: number
activeUsers: number
imageCount?: number
voiceCount?: number
stickerCount?: number
mediaMessageCount?: number
timeSpan: string
generatedAt: string
recordNote: string
footerNote: string
heroParticipants: string[]
avatars: Record<string, string | undefined>
reportMode?: ReportMode
talker?: string
timeRange?: string
warnings?: string[]
}
export interface GroupReportExportRequest {
report: GroupDailyReport
metadata: GroupReportMetadata
}
export interface GroupReportExportResult {
success: boolean
htmlPath?: string
pngPath?: string
imageDataUrl?: string
warnings?: string[]
error?: string
}
+65
View File
@@ -15,8 +15,73 @@ export interface Message {
isSender: boolean
img?: string
name?: string
contentData?: ParsedContent
voiceDataUrl?: string
voiceDuration?: number
localId?: number
createTime?: number
sessionId?: string
}
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 interface ChatTable {
name: string
db_number: string
+2 -1
View File
@@ -1,4 +1,5 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }],
"include": ["src", "vite-env.d.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"],
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*", "src/env.d.ts"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]