Compare commits

...
56 Commits
Author SHA1 Message Date
电摇小子 9bd2c4bb94 chore: 提升版本号 2026-07-14 11:10:42 +08:00
电摇小子 19128fccca docs: 修改README 2026-07-14 11:07:35 +08:00
电摇小子 eaf8e9b07d fix: 修复登录报错key问题 2026-07-14 11:07:35 +08:00
电摇小子 aebf56b3f7 feat: 统一应用品牌图标并修复 macOS 打包路径 2026-07-14 11:07:35 +08:00
电摇小子 ce4b00bcd9 实现 AI 图片理解能力测试 2026-07-14 11:07:35 +08:00
电摇小子 31df97237d 重构 AI 模型配置中心 2026-07-14 11:07:35 +08:00
电摇小子 7b54b611d3 实现图片解密设置页 2026-07-14 11:07:35 +08:00
电摇小子 f1ceef0e5e 实现数据库密钥设置页 2026-07-14 11:07:35 +08:00
电摇小子 ab24185670 精简账号与数据库设置页 2026-07-14 11:07:35 +08:00
电摇小子 842144eba0 fix: 修改登录报错, error改为warn 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d37341ab52 feat: 设置页面新UI 未做完 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 5d041aa137 feat: 完成本地 API 中心与 Reader Skill 流程 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 519c10223d fix: 完善 AI 日报详情状态与解析容错 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d28b579cb4 feat: 优化 AI 日报结果中心信息架构 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 c291c94bd8 feat: 实现 AI 日报历史资产化 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 16a9aed6bb 重构 UI-04 AI 群聊日报工作区 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 7880e874b2 完成 UI-02 和 UI-03 聊天档案界面 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 7e217f7034 增加自动登录环境开关 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 4f0b4884b6 完成 UI-01 应用外壳与设计令牌 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d4f6b755e5 修复 Windows 消息游标回退查询 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 995a95e8dc 修复 Windows 会话名称和密钥显示图标 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 8bb9576f10 feat: 支持点击图片优先打开原图 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 a776830144 feat: 优化 Windows 缓存加载和图片显示 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 ad50939005 feat: 支持微信内置表情渲染 2026-07-14 10:20:45 +08:00
电摇小子and电摇小子 71e970c55c feat: 支持图片密钥配置与内存扫描 2026-07-14 10:20:23 +08:00
电摇小子and电摇小子 8d82fabf57 docs: 更新 Windows 支持说明与 SIP 教程 2026-07-14 10:20:00 +08:00
电摇小子and电摇小子 e8c12696b2 fix: 优化群聊展示与群日报生成 2026-07-14 10:19:40 +08:00
电摇小子and电摇小子 267730d59e feat: 支持 Windows WCDB 解密与打包 2026-07-14 10:15:36 +08:00
电摇小子 3dbc11c19d fix: gitignore 2026-07-14 10:12:05 +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
210 changed files with 26920 additions and 1354 deletions
+17 -4
View File
@@ -1,8 +1,21 @@
# WeChat Database Key (Optional, can be entered in UI)
VITE_DB_KEY=
# DeepSeek API Key (Optional, can be entered in UI)
VITE_DEEPSEEK_API_KEY=
# Auto login on startup with VITE_DB_KEY or saved key.
# Set to true/1/yes/on for local development. Default is disabled.
VITE_AUTO_LOGIN=false
# Message types to filter out (comma separated)
VITE_FILTER_MSG_TYPES=分享消息,图片,表情包,视频
# AI API Configuration (Optional, can be entered in UI)
VITE_DEEPSEEK_API_KEY=
VITE_AI_BASE_URL=https://api.deepseek.com
VITE_AI_MODEL=deepseek-chat
# Message types to filter out (comma separated). Empty means show all message types.
VITE_FILTER_MSG_TYPES=
# Image Decryption Keys (Optional, for WeChat 4.0+ image decryption)
# These are dev fallbacks. End users can fill or auto-fetch them in Settings.
# XOR Key: hex format like 0x40, 0x53 etc.
# AES Key: 16-character string, derived from wxid and code
VITE_IMAGE_XOR_KEY=
VITE_IMAGE_AES_KEY=
+6
View File
@@ -5,3 +5,9 @@ out
.DS_Store
.eslintcache
*.log*
.omc
.codex/
docs/design/
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md
AGENTS.md
+168 -13
View File
@@ -1,41 +1,196 @@
# WechatExplorer
MAC系统 获取微信聊天记录 AI一键生成群聊总结
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
## 📸 预览
## 项目说明
<img src="./public/example1.png" alt="预览图1" />
<img src="./public/example2.png" alt="预览图2" />
<img src="./public/example3.png" alt="预览图3" />
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.0.0)
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
## ✨ 功能特性
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示
- **全局搜索**: 快速搜索聊天内容。
- **AI 智能总结**: 集成 DeepSeek AI,一键总结群聊精华内容,生成话题报告。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot,一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
## 📸 预览
### 日报模板
<details>
<summary>点击查看完整日报模板</summary>
<br />
<img src="./public/report-template-1.png" alt="完整日报模板" />
</details>
### AI 群聊日报界面
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
### 本地 API 与 Reader Skill
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
## 📦 安装说明
### macOS 版本已测 4.1.8
> 微信 macOS `4.1.8.100` 下载地址:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
1. 下载 Releases 中的 `xxx.dmg` 文件。
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
`系统设置 -> 隐私与安全性 -> 仍要打开`
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
### Windows 版本已测 4.1.9
> 微信 Windows `4.1.9.57` 下载地址:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
1. 下载 Releases 中的 `xxx-setup.exe` 安装包。
2. 双击安装,可按安装向导选择安装目录。
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
## 🚀 快速开始
### 使用前置要求
- 微信>=4.0 无法使用
- 微信<=4.0 需要获取自己微信本地数据库的密码, 获取方式参考: [Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/)
- 如果无法获取本地数据库密码 则无法使用当前项目
- **微信版本**:
- 微信 4.0+: macOS / Windows 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
- **macOS 自动获取密钥**: 需要先关闭 SIP,操作方式见 [macOS 关闭 SIP 教程](./docs/mac-disable-sip.md);关闭 SIP 会降低系统安全性,使用完成后建议重新开启。
- 如无法获取本地数据库密码,则无法使用当前项目
- Node.js (推荐 v16+)
- pnpm@7
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
- [DeepSeek API Key](https://www.deepseek.com/) (用于 AI 总结功能)
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
### 环境变量配置 (.env)
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
| 变量名 | 说明 | 示例 |
| ----------------------- | --------------------------- | --------------------------- |
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
#### 图片解密密钥说明
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
这两个密钥可以通过以下方式获取:
1. 在应用登录界面点击“自动获取密钥”
2. 从 WeFlow/Chatlog 设置中导出
3. 在软件“设置 -> 图片解密密钥”中自动获取或手动填写
4. 如自动获取失败,可手动粘贴已获取的密钥
## 🤖 AI 集成(本地 HTTP API
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
### 启用本地 API
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
1. 安装并启动 WechatExplorer
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
3. API 即在 `http://127.0.0.1:6131` 可用
### 7×24 提供 API(菜单栏常驻模式)
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式:
```bash
# 任选一种方式
WXE_TRAY=1 open /Applications/WechatExplorer.app
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
```
启用后:
- macOS dock 图标自动隐藏
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
- 主窗口关闭后 API 服务继续运行
### API 端点一览
| 端点 | 说明 |
| ------------------------------------------------ | --------------------------------------- |
| `GET /api/v1/health` | 健康检查 |
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 |
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
### 安装 Reader Skill,让 Agent 读取和总结群聊
WechatExplorer 已内置 **Reader Skill**,无需手动复制仓库中的 `SKILL.md`
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
2. 打开应用内的 **API** 页面。
3. 在“快速接入”中选择 **Codex****Claude Code**
4. 点击复制安装指令,将指令粘贴给对应的 Agent 执行。
5. 安装完成后,可以直接向 Agent 提问:
> “今天技术交流群聊了什么?”
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
### curl 调试示例(可选)
不使用 Agent 时,也可以通过 `curl` 直接调试本地 HTTP API
```bash
# 健康检查
curl http://127.0.0.1:6131/api/v1/health
# 今天 摸鱼交流群 的聊天记录
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
--data-urlencode "talker=摸鱼交流群" \
--data-urlencode "time=$(date +%Y-%m-%d)"
# 把群昵称解析成 md5
curl -G "http://127.0.0.1:6131/api/v1/resolve" \
--data-urlencode "q=摸鱼交流群"
```
## ⚠️ 免责声明
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
## 🔗 参考
## 🔗 参考致谢
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
- [WeFlow](https://github.com/hicccc77/WeFlow)
- [chatlog](https://github.com/sjzar/chatlog)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+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 已经推送到远程仓库!"
+47
View File
@@ -0,0 +1,47 @@
# macOS 关闭 SIP 教程
SIPSystem Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
## 准备
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
- 需要进入 macOS 恢复模式。
- 请先保存正在编辑的文件,并预留一次重启时间。
## 关闭 SIP
### Intel Mac
1. 关机。
2. 按下开机键后,立刻按住 `Command + R`
3. 保持按住,直到进入 macOS 恢复模式。
### Apple Silicon MacM1/M2/M3/M4
1. 关机。
2. 长按开机键不放。
3. 直到出现启动选项界面后松开。
4. 选择“选项”,进入 macOS 恢复模式。
### 在恢复模式中执行命令
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**
2. 选择 **Terminal(终端)**
3. 在终端中输入:
```bash
csrutil disable
```
4. 按回车执行。
5. 看到关闭成功提示后,重启电脑。
## 重新开启 SIP
如果后续不再需要关闭 SIP,建议重新进入恢复模式,在终端中执行:
```bash
csrutil enable
```
然后重启电脑。
+336
View File
@@ -0,0 +1,336 @@
---
name: wechatexplorer-reader
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app。
---
# WechatExplorer Reader
通过本地 HTTP API(`http://127.0.0.1:6131`)读取 WechatExplorer 已经解锁的微信数据库内容。
## 数据源
- **本服务由 WechatExplorer.app 提供**,数据完全在本地处理,不会上传任何服务器
- 用户必须在 WechatExplorer 主窗口完成**首次密钥配置**(解锁 WCDB 数据库)
- 默认监听 `127.0.0.1:6131`,仅本机可访问,无需鉴权
## 前置条件
1. **安装并启动 WechatExplorer.app**(从项目 release 页面下载)
2. **首次启动时完成密钥配置**:在主界面第一步输入微信数据库密钥(64 位 hex),完成 WCDB 初始化
3. **如需 7×24 提供 API**:用 `WXE_TRAY=1``--tray` 参数启动 app,启用菜单栏常驻模式(主窗口关闭后服务仍在)
## API 列表
GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图)。所有端点返回 JSON。
| 端点 | 用途 | 关键参数 |
|------|------|---------|
| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — |
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — |
| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) |
| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `keyword` |
| `GET /api/v1/recent_chat` | 最近会话 | `limit`(默认 50) |
| `GET /api/v1/chatlog` | 聊天记录 | `talker``time``startTime`/`endTime` |
| `GET /api/v1/group_snapshot` | 群成员快照 | `md5` |
| `GET /api/v1/resolve` | 把昵称/wxid/md5 解析成 md5 | `q` |
| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) |
### `talker` 参数可接受的值
`chatlog``recent_chat``talker` / 列表项 ID 支持以下三种形式,服务端会按 `nickname → wxid → md5` 顺序匹配:
1. **群昵称 / 好友备注**(模糊匹配,如 `技术交流``摸鱼群`)
2. **微信 wxid**(如 `wxid_abc123``gh_xxxxx@chatroom`)
3. **会话 md5**(如 `49023470180@chatroom` 的 md5 哈希,可在 `contact` 接口里看到)
不确定时先调 `GET /api/v1/resolve?q=<输入>` 校验,返回 `{ md5, m_nsUsrName, m_nsNickName, type, ... }`
### `chatroom` 与 `contact?type=group` 字段一致性
`/chatroom``/contact?type=group` 返回的是**同一个集合**(都是 `listContacts().filter(type==='group')`),字段也完全一致:
```json
{
"m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker
"m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer
"type": "group",
"md5": "..."
}
```
需要 `displayName` 时从 `m_nsNickName` 里解析;需要拉消息就传 `m_nsUsrName` 当 talker。
## 时间范围格式(`time` 参数)
支持以下格式:
| 输入 | 含义 |
|------|------|
| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 |
| `2026-07-01~2026-07-03` | 日期范围(闭区间) |
| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) |
| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 |
也可以直接传 unix 秒级时间戳作为 `startTime``endTime`
### "今天 / 昨天 / 本周" 的时区语义
所有 `time` / `startTime` / `endTime` 都按**用户本机时区**解析(由 `current_time` 里的 `timezone` 字段给出,典型为 `Asia/Shanghai`)。含义如下:
- "今天 2026-07-03" → 本机 2026-07-03 00:00:00 ~ 23:59:59(北京时间 24 小时),**不是** UTC 当天
- "昨天" → 本机昨天 0 点 ~ 23:59:59
- "本周" → 本周一 0 点 ~ 当前时刻(按本机时区所在周的周一)
跨时区时(如用户在国外):仍以本机时区为准,需要按 UTC 处理时显式传 unix 时间戳。
## 时间预检工作流(Time-Aware Workflow)
**重要**:只要用户请求中包含"今天"、"昨天"、"本周"、"刚才"等相对时间概念,**禁止**直接生成日期字符串。
**步骤 1**:先调用 `current_time` 工具获取本地 RFC3339 时间。
**步骤 2**:根据返回的时间计算对应的 `time` 参数。
**步骤 3**:用计算后的参数调 `chatlog`
示例:
- 用户: "今天 摸鱼交流群 聊了啥?"
- AI: 先 `GET /api/v1/current_time` → 得到 `2026-07-03T14:30:00+08:00` → 计算 `time=2026-07-03``GET /api/v1/chatlog?talker=摸鱼交流群&time=2026-07-03`
## 多步上下文检索(强制)
当查询特定话题或特定发送者发言时,**必须**按以下流程操作:
1. **初步定位**:用 `contact``chatroom` 端点确定群聊 md5 / wxid
2. **粗查**:用 `chatlog` + 较宽时间范围找到相关消息时间点
3. **精查**:对每个关键时间点分别查前后 15-30 分钟(不带任何 keyword 过滤),用完整上下文分析
**禁止**:仅凭一次粗查结果直接回答用户。
## 生成群日报(POST /api/v1/report)
当用户希望输出**可视化群日报**(长图 PNG + HTML 邮件版)时,用这个端点。WechatExplorer 内置 `mobile_daily_report.html` 模板,渲染后会同时落盘 `htmlPath``pngPath`,并返回 `imageDataUrl` 可直接预览。
### 请求体(`GroupReportExportRequest`)
```json
{
"report": {
"overview": "一句话总览,20-80 字",
"topics": [
{
"title": "话题标题",
"timeRange": "10:00-12:30",
"heat": "高", // "高" | "中" | "低"
"participants": ["张三", "李四"],
"summary": "本话题讨论了什么",
"conclusion": "可选,达成的结论",
"keywords": ["关键词1", "关键词2"]
}
],
"resources": [
{ "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" }
],
"importantMessages": [
{ "sender": "张三", "time": "10:23", "content": "原消息文本", "note": "为什么重要" }
],
"quotes": [
{
"messages": [{ "sender": "李四", "content": "原话1" }, { "sender": "王五", "content": "原话2" }],
"note": "为什么这些话值得引用"
}
],
"qa": [
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
],
"analytics": {
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
"activeTimeline": "10:00-12:00 为最活跃时段",
"topSpeakers": [{ "name": "张三", "count": 58 }]
},
"keywords": ["高频词1", "高频词2"]
},
"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 支持情况调整)
+34 -10
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,24 +15,45 @@ extraMetadata:
main: out/main/index.js
asarUnpack:
- resources/**
extraResources:
- from: resources
to: resources
filter:
- '**/*'
- from: docs/skill/wechatexplorer-reader
to: skill/wechatexplorer-reader
filter:
- '**/*'
win:
executableName: wechatexplorer
icon: icon.ico
# WCDB's Windows runtime checks the host executable name. The dev runtime is
# electron.exe, so keep the packaged executable compatible while preserving
# WechatExplorer as the product/shortcut name.
executableName: electron
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
icon: icon.icns
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
# The bundled WCDB bridge accepts Electron as its internal host name. The
# public app name and bundle identifier remain WechatExplorer-specific.
CFBundleName: Electron
CFBundleDisplayName: WechatExplorer
NSCameraUsageDescription: Application requests access to the device's camera.
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-${arch}.${ext}
linux:
icon: icon.png
target:
- AppImage
- snap
@@ -42,5 +64,7 @@ appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
provider: github
owner: Wxw-Gu
repo: WechatExplorer
releaseType: draft
+13 -1
View File
@@ -3,7 +3,19 @@ import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {},
main: {
build: {
rollupOptions: {
input: {
index: resolve('src/main/index.ts')
},
output: {
entryFileNames: '[name].js'
},
external: ['koffi']
}
}
},
preload: {},
renderer: {
resolve: {
+25 -11
View File
@@ -1,15 +1,20 @@
{
"name": "wechatexplorer",
"version": "1.0.2",
"description": "mac 版本获取微信聊天记录, AI群聊总结助手",
"version": "2.1.1",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [
"wechat",
"chat",
"mac微信",
"windows微信",
"微信聊天记录",
"AI群聊总结助手"
],
"author": "Qingmao",
"repository": {
"type": "git",
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
},
"main": "./out/main/index.js",
"scripts": {
"format": "prettier --write .",
@@ -17,33 +22,42 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
"cp:env": "node scripts/ensure-env.cjs",
"prepare:env": "node scripts/ensure-env.cjs",
"predev": "node scripts/ensure-env.cjs",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
"build:mac:x64": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"better-sqlite3-multiple-ciphers": "^12.5.0",
"fs-extra": "^11.3.2",
"html-to-image": "^1.11.13",
"openai": "^6.10.0"
"fzstd": "^0.1.1",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@rollup/rollup-darwin-arm64": "^4.62.2",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron": "^43.0.0",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
+241 -274
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 27 KiB

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.
BIN
View File
Binary file not shown.
+499
View File
@@ -0,0 +1,499 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{REPORT_TITLE}}</title>
<style>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
html {
width: 430px;
scrollbar-width: none;
}
body {
margin: 0;
width: 430px;
background: #f3f5f7;
color: #1f2933;
font-family:
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
}
.hero,
.card,
.section {
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
padding: 20px;
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
font-size: 23px;
line-height: 1.2;
margin: 0 0 8px;
font-weight: 900;
}
.sub {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
.record-note {
color: #485465;
font-weight: 650;
}
.overview {
margin-top: 2px;
}
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
flex: 0 0 auto;
}
.avatar-grid img,
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: #f7faf9;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
min-width: 0;
overflow: hidden;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
white-space: nowrap;
line-height: 1.25;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 14px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 900;
margin-bottom: 12px;
}
.section-title::before {
content: '';
width: 5px;
height: 20px;
border-radius: 99px;
background: #07c160;
}
.card {
padding: 14px;
margin-top: 10px;
box-shadow: none;
border: 1px solid #edf0f2;
}
.topic-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.topic-title-row h3 {
font-size: 16px;
line-height: 1.35;
margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.hot {
background: #fff4e5;
color: #d46b08;
}
.blue {
background: #eef5ff;
color: #1677ff;
}
.red {
background: #fff1f0;
color: #ff4d4f;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
font-size: 13px;
line-height: 1.65;
margin: 10px 0 0;
}
.participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
}
.person-chip img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.keywords span {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
}
.resource {
padding: 11px 12px;
background: #f7f8fa;
border-radius: 12px;
margin-top: 8px;
font-size: 13px;
line-height: 1.55;
}
.resource b {
color: #1677ff;
}
.important-card {
display: flex;
gap: 10px;
background: #f7faf9;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.important-card > .avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
}
.important-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #667085;
}
.important-meta b {
color: #1f2933;
}
.important-text {
font-size: 13px;
line-height: 1.55;
margin-top: 5px;
}
.important-note {
margin-top: 8px;
padding: 7px 9px;
border-left: 3px solid #07c160;
background: #fff;
border-radius: 8px;
color: #07a352;
font-size: 12px;
line-height: 1.45;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.chat-msg {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.chat-name {
font-size: 11px;
color: #667085;
margin-bottom: 4px;
}
.chat-bubble {
background: #fff;
border-radius: 4px 12px 12px 12px;
padding: 9px 10px;
font-size: 13px;
line-height: 1.5;
}
.quote-note {
background: #fff8e1;
border-radius: 10px;
padding: 9px 10px;
margin-top: 10px;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card {
background: #f8fafc;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.qa-card b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.bar-row {
display: grid;
grid-template-columns: 82px 1fr;
gap: 8px;
align-items: center;
margin-top: 9px;
font-size: 12px;
}
.bar {
height: 10px;
background: #edf1f5;
border-radius: 999px;
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: #07c160;
border-radius: 999px;
}
.rank {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank img {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
}
.rank b {
font-size: 13px;
}
.rank span {
margin-left: auto;
color: #8a94a6;
font-size: 12px;
}
.cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cloud-tags span {
padding: 6px 10px;
border-radius: 999px;
background: #f2f4f7;
color: #485465;
font-weight: 800;
}
.cloud-tags .xl {
font-size: 20px;
color: #07a352;
background: #e9f8ef;
}
.cloud-tags .lg {
font-size: 17px;
color: #1677ff;
background: #eef5ff;
}
.cloud-tags .md {
font-size: 15px;
color: #d46b08;
background: #fff4e5;
}
.footer {
padding: 16px 4px 0;
color: #98a2b3;
text-align: center;
font-size: 11px;
line-height: 1.8;
}
.muted {
color: #8a94a6;
}
.empty-section {
display: none;
}
@media (max-width: 430px) {
html,
body {
width: 100%;
}
.report {
width: 100%;
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
</style>
</head>
<body>
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">
<div>{{DATE_RANGE}}</div>
<div class="record-note">{{RECORD_NOTE}}</div>
<div class="overview">{{OVERVIEW}}</div>
</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
</div>
</header>
<section class="section topics">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
{{IMPORTANT_MESSAGES}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
{{QUOTE_BLOCKS}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
</div>
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
</section>
<footer class="footer">
数据来源:WechatExplorer · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
</main>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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)
}
}
+13
View File
@@ -0,0 +1,13 @@
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const source = path.join(root, '.env.example')
const target = path.join(root, '.env')
if (!fs.existsSync(source) || fs.existsSync(target)) {
process.exit(0)
}
fs.copyFileSync(source, target)
console.log('[ensure-env] created .env from .env.example')
+48
View File
@@ -0,0 +1,48 @@
const fs = require('node:fs')
const path = require('node:path')
const runtimeNames = [
'msvcp140.dll',
'msvcp140_1.dll',
'vcruntime140.dll',
'vcruntime140_1.dll'
]
function copyIfDifferent(sourcePath, targetPath) {
const source = fs.statSync(sourcePath)
const targetExists = fs.existsSync(targetPath)
if (targetExists) {
const target = fs.statSync(targetPath)
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
return false
}
}
fs.copyFileSync(sourcePath, targetPath)
return true
}
function main() {
if (process.platform !== 'win32') return
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32')
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist')
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) return
let copiedCount = 0
for (const name of runtimeNames) {
const sourcePath = path.join(sourceDir, name)
const targetPath = path.join(targetDir, name)
if (!fs.existsSync(sourcePath)) continue
if (copyIfDifferent(sourcePath, targetPath)) copiedCount += 1
}
if (copiedCount > 0) {
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`)
}
}
main()
@@ -0,0 +1,45 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const filePath = path.join(
__dirname,
'..',
'src',
'renderer',
'src',
'features',
'api-center',
'utils',
'buildSkillInstallInstruction.ts'
)
const source = fs.readFileSync(filePath, 'utf8')
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const moduleExports = {}
new Function('exports', 'require', 'module', output)(moduleExports, require, { exports: moduleExports })
const { buildSkillInstallInstruction } = moduleExports
const local = { type: 'local', directoryPath: 'C:/skill/wechatexplorer-reader', skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', version: 'v1.0' }
for (const [target, expected] of [
['codex', 'Codex 项目或用户 Skill 目录'],
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
['openclaw', '作为 WechatExplorer Reader Skill 安装'],
['generic', '读取并安装']
]) {
const text = buildSkillInstallInstruction({ target, source: local, apiBaseUrl: { host: '127.0.0.1', port: 6131 } })
assert.match(text, new RegExp(expected))
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
}
assert.match(
buildSkillInstallInstruction({ target: 'codex', source: local, apiBaseUrl: { host: '0.0.0.0', port: 7000 } }),
/http:\/\/127\.0\.0\.1:7000\/api\/v1\/health/
)
assert.match(
buildSkillInstallInstruction({ target: 'generic', source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' }, apiBaseUrl: { host: 'localhost', port: 6131 } }),
/https:\/\/example\.com\/skill/
)
console.log('skill install instruction tests passed')
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DEEPSEEK_API_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+79
View File
@@ -0,0 +1,79 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
interface StoredKeys {
version: 1
keys: Record<string, string>
}
export class AIProviderKeyStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'ai-provider-keys.bin')
}
get(providerId: string): { success: boolean; key?: string; error?: string; available: boolean } {
const result = this.read()
return { ...result, key: result.data?.keys[providerId] }
}
save(providerId: string, key: string): { success: boolean; error?: string } {
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
const current = this.read()
if (!current.success) return { success: false, error: current.error }
const data = current.data || { version: 1 as const, keys: {} }
data.keys[providerId] = key
return this.write(data)
}
clear(providerId: string): { success: boolean; error?: string } {
const current = this.read()
if (!current.success) return { success: false, error: current.error }
if (!current.data?.keys[providerId]) return { success: true }
delete current.data.keys[providerId]
try {
if (Object.keys(current.data.keys).length === 0) fs.removeSync(this.filePath)
else return this.write(current.data)
return { success: true }
} catch {
return { success: false, error: '无法清除 AI Provider 密钥' }
}
}
private read(): {
success: boolean
data?: StoredKeys
error?: string
available: boolean
} {
const available = safeStorage.isEncryptionAvailable()
if (!fs.existsSync(this.filePath)) {
return { success: true, data: { version: 1, keys: {} }, available }
}
if (!available) return { success: false, error: '系统安全存储不可用', available }
try {
const data = JSON.parse(
safeStorage.decryptString(fs.readFileSync(this.filePath))
) as StoredKeys
if (data.version !== 1 || !data.keys) throw new Error('invalid AI key store')
return { success: true, data, available }
} catch {
return { success: false, error: 'AI Provider 安全存储不可读取', available }
}
}
private write(data: StoredKeys): { success: boolean; error?: string } {
try {
fs.ensureDirSync(path.dirname(this.filePath))
fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), {
mode: 0o600
})
fs.chmodSync(this.filePath, 0o600)
return { success: true }
} catch {
return { success: false, error: 'AI Provider 密钥保存失败' }
}
}
}
+88
View File
@@ -0,0 +1,88 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type { DatabaseKeyStorageResult } from '../shared/database-key'
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
export const isValidDatabaseKey = (value: string): boolean =>
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
export class DatabaseKeyStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
}
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
return {
saved: await fs.pathExists(this.filePath),
encryptionAvailable: safeStorage.isEncryptionAvailable()
}
}
async load(): Promise<DatabaseKeyStorageResult> {
try {
const status = await this.getStatus()
if (!status.saved) return { success: true, ...status }
if (!status.encryptionAvailable) {
return { success: false, error: '系统安全存储不可用', ...status }
}
const encrypted = await fs.readFile(this.filePath)
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
if (!isValidDatabaseKey(key)) {
return { success: false, error: '已保存的密钥格式无效', ...status }
}
return { success: true, key, ...status }
} catch (error) {
const status = await this.getStatus()
return {
success: false,
error: error instanceof Error ? error.message : String(error),
...status
}
}
}
async save(rawKey: string): Promise<DatabaseKeyStorageResult> {
const key = normalizeDatabaseKey(rawKey)
if (!isValidDatabaseKey(key)) {
return {
success: false,
error: '密钥必须是 64 位十六进制字符',
saved: await fs.pathExists(this.filePath),
encryptionAvailable: safeStorage.isEncryptionAvailable()
}
}
if (!safeStorage.isEncryptionAvailable()) {
return {
success: false,
error: '系统安全存储不可用',
saved: await fs.pathExists(this.filePath),
encryptionAvailable: false
}
}
try {
await fs.ensureDir(path.dirname(this.filePath))
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
await fs.chmod(this.filePath, 0o600)
return { success: true, key, saved: true, encryptionAvailable: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
saved: await fs.pathExists(this.filePath),
encryptionAvailable: true
}
}
}
async clear(): Promise<{ success: boolean; error?: string }> {
try {
await fs.remove(this.filePath)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
+363
View File
@@ -0,0 +1,363 @@
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 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))
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.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
<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 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 maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
const heatBars = report.analytics.topicHeat
.map(
(item) =>
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
)
.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('')
let html = await fs.readFile(templatePath(), 'utf8')
const values: Record<string, string> = {
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
OVERVIEW: escapeHtml(report.overview),
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(metadata.messageCount),
ACTIVE_USERS: String(metadata.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
TOPIC_COUNT: String(report.topics.length),
TOPIC_CARDS: topicCards,
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
RESOURCE_ITEMS: resourceItems,
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
IMPORTANT_MESSAGES: importantMessages,
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
QUOTE_BLOCKS: quoteBlocks,
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
QA_CARDS: qaCards,
HEAT_BARS: heatBars,
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
CLOUD_TAGS: cloudTags,
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> => {
console.log(`[GroupReport] capture begin html=${htmlPath}`)
const reportWindow = new BrowserWindow({
show: false,
width: 430,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
})
try {
await reportWindow.loadFile(htmlPath)
console.log('[GroupReport] capture loaded html')
await reportWindow.webContents.executeJavaScript(`Promise.all([
document.fonts.ready,
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}))
])`)
console.log('[GroupReport] capture assets ready')
const metrics = (await reportWindow.webContents.executeJavaScript(`({
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
})`)) as { width: number; height: number }
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width)))
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
reportWindow.setContentSize(width, height)
await new Promise((resolve) => setTimeout(resolve, 100))
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
const png = image.toPNG()
if (png.length < 1000) throw new Error('生成的日报图片为空')
await fs.writeFile(pngPath, png)
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
return `data:image/png;base64,${png.toString('base64')}`
} finally {
reportWindow.destroy()
}
}
export const exportGroupReport = async (
request: GroupReportExportRequest
): Promise<GroupReportExportResult> => {
try {
// === enrich 在 render 之前:从群成员快照反推真头像 ===
await enrichAvatarsFromGroup(request.metadata)
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
const pngEndedAt = new Date()
return {
success: true,
htmlPath,
pngPath,
imageDataUrl,
exportTimings: {
html: {
startedAt: htmlStartedAt.toISOString(),
endedAt: htmlEndedAt.toISOString(),
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
},
png: {
startedAt: pngStartedAt.toISOString(),
endedAt: pngEndedAt.toISOString(),
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
}
},
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
}
} catch (error) {
console.error('[GroupReport] export failed:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
+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 }
}
}
+567
View File
@@ -0,0 +1,567 @@
import { basename, dirname, extname, join } from 'path'
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
import crypto from 'crypto'
import os from 'os'
import { Wcdb4Client } from './wcdb4-client'
export class ImageDecryptService {
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private xorKey: number = 0
private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
// 解析 XOR Key (支持 0x40 或 64 格式)
const xorHex = xorKey.trim().toLowerCase()
if (xorHex.startsWith('0x')) {
this.xorKey = parseInt(xorHex, 16)
} else {
this.xorKey = parseInt(xorHex, 10)
}
// AES Key 直接使用
this.aesKey = aesKey.trim()
this.wcdb4Client = wcdb4Client || null
}
/**
* 获取账号目录
*/
private getAccountDir(): string | null {
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
return wcdbAccountRoot
}
const homeDir = os.homedir()
const accountRoot = join(
homeDir,
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
)
if (!existsSync(accountRoot)) {
console.log('[ImageDecrypt] account root not found:', accountRoot)
return null
}
const accounts = readdirSync(accountRoot)
.filter((name) => {
const fullPath = join(accountRoot, name)
try {
return statSync(fullPath).isDirectory()
} catch {
return false
}
})
.map((name) => ({
name,
mtime: statSync(join(accountRoot, name)).mtimeMs
}))
.sort((a, b) => b.mtime - a.mtime)
if (accounts.length === 0) {
console.log('[ImageDecrypt] no accounts found')
return null
}
// 返回最新的账号目录
return join(accountRoot, accounts[0].name)
}
/**
* 根据 md5 查找图片文件 (WechatExplorer 风格)
*/
findImageFile(
md5?: string,
imageDatName?: string,
options?: { allowThumbnail?: boolean }
): string | null {
const accountDir = this.getAccountDir()
if (!accountDir) return null
const allowThumbnail = options?.allowThumbnail !== false
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
console.log('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5,
imageDatName: normalizedDatName,
accountDir,
allowThumbnail
})
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
if (fullPath && existsSync(fullPath)) {
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
console.log('[ImageDecrypt] hardlink hit:', selected)
return selected
}
}
}
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) {
console.log('[ImageDecrypt] attach dir not found:', attachDir)
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
}
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
if (searchKeys.length === 0) return null
for (const key of searchKeys) {
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
if (directHit) return directHit
}
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
if (legacyHit) return legacyHit
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null
}
private fastProbabilisticSearch(
attachDir: string,
datName: string,
allowThumbnail = true
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
const variants = this.buildPreferredDatNames(normalized)
if (/^[a-f0-9]{32}$/.test(normalized)) {
const dir1 = normalized.substring(0, 2)
const dir2 = normalized.substring(2, 4)
for (const variant of variants) {
const candidates = [
join(attachDir, dir1, dir2, variant),
join(attachDir, dir1, dir2, 'Img', variant),
join(attachDir, dir1, dir2, 'Image', variant),
join(attachDir, dir1, dir2, 'image', variant)
]
const found = this.getLargestExistingPath(candidates, allowThumbnail)
if (found) {
console.log('[ImageDecrypt] prefix path hit:', found)
return found
}
}
}
try {
const sessionDirs = readdirSync(attachDir).filter(
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)
)
const now = new Date()
const months: string[] = []
for (let i = 0; i < 24; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
for (const sessDir of sessionDirs) {
for (const month of months) {
for (const sub of ['Img', 'Image', 'image']) {
const imgDir = join(attachDir, sessDir, month, sub)
if (!existsSync(imgDir)) continue
const found = this.getLargestExistingPath(
variants.map((variant) => join(imgDir, variant)),
allowThumbnail
)
if (found) {
console.log('[ImageDecrypt] found at:', found)
return found
}
}
}
}
} catch (e) {
console.log('[ImageDecrypt]遍历目录失败:', e)
}
return null
}
private findImageFileInLegacyDirs(
accountDir: string,
datName: string,
allowThumbnail = true
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
const roots = [
join(accountDir, 'FileStorage', 'Image'),
join(accountDir, 'FileStorage', 'Image2'),
join(accountDir, 'FileStorage', 'MsgImg')
].filter((root) => existsSync(root))
for (const root of roots) {
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
if (found) return found
}
return null
}
private recursiveFindDat(
dir: string,
datName: string,
depth: number,
allowThumbnail = true
): string | null {
if (depth < 0) return null
try {
const variants = new Set(
this.buildPreferredDatNames(datName).filter(
(name) => allowThumbnail || !this.isThumbnailName(name)
)
)
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && variants.has(entry.toLowerCase())) {
console.log('[ImageDecrypt] legacy path hit:', fullPath)
return fullPath
}
}
for (const entry of entries) {
const fullPath = join(dir, entry)
if (!statSync(fullPath).isDirectory()) continue
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
if (found) return found
}
} catch {
return null
}
return null
}
/**
* 解密图片文件并返回 Buffer
*/
decryptImage(datPath: string): Buffer | null {
if (!existsSync(datPath)) {
console.log('[ImageDecrypt] file not found:', datPath)
return null
}
try {
const version = this.getDatVersion(datPath)
console.log(
'[ImageDecrypt] dat version:',
version,
'file:',
datPath,
'aesKey present:',
!!this.aesKey
)
let decrypted: Buffer
if (version === 1) {
console.log('[ImageDecrypt] using V1 (default AES key)')
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
decrypted = this.decryptDatV4(datPath, key)
} else if (version === 2) {
console.log('[ImageDecrypt] using V2 (user AES key)')
if (!this.aesKey) {
console.log('[ImageDecrypt] no AES key configured')
return null
}
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
decrypted = this.decryptDatV4(datPath, key)
} else {
console.log('[ImageDecrypt] unsupported dat version:', version)
return null
}
return decrypted
} catch (error) {
console.error('[ImageDecrypt] decrypt error:', error)
return null
}
}
/**
* 将解密后的图片转换为 base64
*/
decryptImageToBase64(datPath: string): string | null {
if (!extname(datPath).toLowerCase().includes('dat')) {
const data = readFileSync(datPath)
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
const mimeType = this.getMimeType(ext)
return `data:${mimeType};base64,${data.toString('base64')}`
}
const decrypted = this.decryptImage(datPath)
if (!decrypted) return null
const unwrapped = this.unwrapWxgf(decrypted)
const ext = this.detectImageExtension(unwrapped)
if (!ext) {
console.log('[ImageDecrypt] unknown image format')
return null
}
const mimeType = this.getMimeType(ext)
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
}
/**
* 检测 DAT 文件版本
*/
private getDatVersion(inputPath: string): number {
const bytes = readFileSync(inputPath)
if (bytes.length < 6) {
return 0
}
const signature = bytes.subarray(0, 6)
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
return 1
}
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
return 2
}
return 0
}
/**
* V4 解密 - AES + XOR
*/
private decryptDatV4(inputPath: string, aesKey: Buffer): Buffer {
const bytes = readFileSync(inputPath)
if (bytes.length < 0x0f) {
throw new Error('文件太小,无法解析')
}
const header = bytes.subarray(0, 0x0f)
const data = bytes.subarray(0x0f)
const aesSize = this.bytesToInt32(header.subarray(6, 10))
const xorSize = this.bytesToInt32(header.subarray(10, 14))
// 对齐 AES 数据到 16 字节边界
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) {
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
}
// 解密 AES 数据
const aesData = data.subarray(0, alignedAesSize)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
unpadded = this.strictRemovePadding(decrypted)
}
// 解密 XOR 数据
const remaining = data.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常:XOR 数据长度不合法')
}
let rawData: Buffer
let xoredData: Buffer
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常:原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
xoredData[i] = xorData[i] ^ this.xorKey
}
} else {
rawData = remaining
xoredData = Buffer.alloc(0)
}
return Buffer.concat([unpadded, rawData, xoredData])
}
/**
* 检测图片扩展名
*/
private detectImageExtension(buffer: Buffer): string | null {
if (buffer.length < 4) return null
const SIGNATURES: Record<string, Buffer> = {
'.jpg': Buffer.from([0xff, 0xd8, 0xff]),
'.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]),
'.gif': Buffer.from([0x47, 0x49, 0x46, 0x38]),
'.bmp': Buffer.from([0x42, 0x4d]),
'.webp': Buffer.from([0x52, 0x49, 0x46, 0x46])
}
for (const [ext, sig] of Object.entries(SIGNATURES)) {
if (this.compareBytes(buffer.subarray(0, sig.length), sig)) {
return ext
}
}
return null
}
private getMimeType(ext: string): string {
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp'
}
return mimeTypes[ext] || 'image/jpeg'
}
private normalizeDatBase(value: string): string {
const lower = String(value || '')
.trim()
.toLowerCase()
if (!lower) return ''
const file = lower.split('/').pop()?.split('\\').pop() || lower
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase()
}
private buildPreferredDatNames(baseName: string): string[] {
const base = this.normalizeDatBase(baseName)
if (!base) return []
return [
`${base}.dat`,
`${base}_hd.dat`,
`${base}_h.dat`,
`${base}_b.dat`,
`${base}_w.dat`,
`${base}_c.dat`,
`${base}_t.dat`,
`${base}.thumb.dat`,
`${base}_thumb.dat`
]
}
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
const actualDir = dirname(inputPath)
const base = this.normalizeDatBase(basename(inputPath))
const variants = this.buildPreferredDatNames(base)
const ordered = allowThumbnail
? variants
: variants.filter((name) => !this.isThumbnailName(name))
const largest = this.getLargestExistingPath(
ordered.map((variant) => join(actualDir, variant)),
allowThumbnail
)
if (largest) return largest
return inputPath
}
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
candidates
.filter((candidate) => existsSync(candidate))
.map((candidate) => {
try {
return { candidate, size: statSync(candidate).size }
} catch {
return { candidate, size: 0 }
}
})
.sort((left, right) => right.size - left.size)
const nonThumb = toSized(paths.filter((candidate) => !this.isThumbnailName(basename(candidate))))
if (nonThumb[0]) return nonThumb[0].candidate
if (!allowThumbnail) return null
const existing = toSized(paths)
return existing[0]?.candidate || null
}
private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
}
isThumbnailFile(filePath: string): boolean {
return this.isThumbnailName(basename(filePath))
}
private unwrapWxgf(buffer: Buffer): Buffer {
if (
buffer.length < 20 ||
buffer[0] !== 0x77 ||
buffer[1] !== 0x78 ||
buffer[2] !== 0x67 ||
buffer[3] !== 0x66
) {
return buffer
}
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i += 1) {
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
return buffer.subarray(i)
}
if (
buffer[i] === 0x89 &&
buffer[i + 1] === 0x50 &&
buffer[i + 2] === 0x4e &&
buffer[i + 3] === 0x47
) {
return buffer.subarray(i)
}
}
return buffer
}
private uniq(values: string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
private bytesToInt32(bytes: Buffer): number {
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
private compareBytes(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
private strictRemovePadding(buffer: Buffer): Buffer {
if (buffer.length === 0) return buffer
const lastByte = buffer[buffer.length - 1]
if (lastByte <= 16 && lastByte > 0) {
const paddingLength = lastByte
let valid = true
for (let i = buffer.length - paddingLength; i < buffer.length; i++) {
if (buffer[i] !== lastByte) {
valid = false
break
}
}
if (valid) {
return buffer.subarray(0, buffer.length - paddingLength)
}
}
return buffer
}
}
+99
View File
@@ -0,0 +1,99 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
export interface StoredImageKeyEntry {
xorKey: string
aesKey: string
updatedAt: number
}
interface StoredImageKeyFile {
version: 1
accounts: Record<string, StoredImageKeyEntry>
}
interface StoreReadResult {
success: boolean
data?: StoredImageKeyFile
error?: string
encryptionAvailable: boolean
}
export class ImageKeyStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'wechat-image-keys.bin')
}
get(accountId: string): StoreReadResult & { entry?: StoredImageKeyEntry } {
const result = this.read()
return { ...result, entry: result.data?.accounts[accountId] }
}
save(
accountId: string,
entry: Omit<StoredImageKeyEntry, 'updatedAt'>
): { success: boolean; entry?: StoredImageKeyEntry; error?: string } {
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
const current = this.read()
if (!current.success && fs.existsSync(this.filePath)) {
return { success: false, error: '无法读取现有图片密钥配置' }
}
const nextEntry = { ...entry, updatedAt: Date.now() }
const data: StoredImageKeyFile = current.data || { version: 1, accounts: {} }
data.accounts[accountId] = nextEntry
const saved = this.write(data)
return saved.success ? { success: true, entry: nextEntry } : saved
}
clear(accountId: string): { success: boolean; error?: string } {
const current = this.read()
if (!current.success) return { success: false, error: current.error }
if (!current.data?.accounts[accountId]) return { success: true }
delete current.data.accounts[accountId]
try {
if (Object.keys(current.data.accounts).length === 0) fs.removeSync(this.filePath)
else return this.write(current.data)
return { success: true }
} catch {
return { success: false, error: '无法清除图片密钥配置' }
}
}
private read(): StoreReadResult {
const encryptionAvailable = safeStorage.isEncryptionAvailable()
if (!fs.existsSync(this.filePath)) {
return {
success: true,
data: { version: 1, accounts: {} },
encryptionAvailable
}
}
if (!encryptionAvailable) {
return { success: false, error: '系统安全存储不可用', encryptionAvailable }
}
try {
const decrypted = safeStorage.decryptString(fs.readFileSync(this.filePath))
const data = JSON.parse(decrypted) as StoredImageKeyFile
if (data.version !== 1 || !data.accounts) throw new Error('invalid image key store')
return { success: true, data, encryptionAvailable }
} catch {
return { success: false, error: '图片密钥安全存储不可读取', encryptionAvailable }
}
}
private write(data: StoredImageKeyFile): { success: boolean; error?: string } {
try {
fs.ensureDirSync(path.dirname(this.filePath))
fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), {
mode: 0o600
})
fs.chmodSync(this.filePath, 0o600)
return { success: true }
} catch {
return { success: false, error: '图片密钥保存失败' }
}
}
}
+613 -160
View File
@@ -1,30 +1,109 @@
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard } from 'electron'
import './preload-env'
import {
app,
shell,
BrowserWindow,
ipcMain,
nativeImage,
clipboard,
Menu,
Tray,
dialog
} from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { WechatDb, Contact, WechatMessage } from './wechat-db'
import { WechatDb } from './wechat-db'
import { bootstrapWcdbNative } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser'
import { ImageDecryptService } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import {
deleteGeneratedReport,
listGeneratedReports,
saveGeneratedReport
} from './report-history-service'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
import type {
AIChatRequestOptions,
AIProviderConfig,
AIVisionTestRequest,
LegacyAIConfig
} from '../shared/ai-provider'
import { DatabaseKeyStore } from './database-key-store'
import { ImageKeyConfigService } from './services/image-key-config-service'
import { AIProviderService } from './services/ai-provider-service'
import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service'
import { apiServer } from './http-server'
import { skillResourceService } from './services/skill-resource-service'
import { testLocalApiRequest } from './services/local-api-test-service'
import { isWindowsWechatRunning } from './services/wechat-process-status'
import {
inspectImageDecryptionStatus,
testImageDecryption
} from './services/image-decryption-status-service'
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
import {
getBootstrapCache,
getCachedMessages,
mergeBootstrapAvatars,
mergeCachedContactAvatars,
saveBootstrapContacts,
saveBootstrapSelf,
saveCachedMessages
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
let wechatDb: WechatDb | null = null
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
// handler. Wrap console.* before any other module logs anything.
installSafeConsole()
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
10000: '系统消息'
let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore()
const imageKeyConfigService = new ImageKeyConfigService()
const aiProviderService = new AIProviderService()
const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin()
let tray: Tray | null = null
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
// WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
const config = imageKeyConfigService.getConfig()
return {
xorKey: config.xorKey || '0x40',
aesKey: config.aesKey || ''
}
}
function createWindow(): void {
// 创建浏览器窗口
// 鍒涘缓娴忚鍣ㄧ獥鍙?
const mainWindow = new BrowserWindow({
width: 1200,
height: 670,
width: 1400,
height: 800,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
icon: appIconPath,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
@@ -40,8 +119,8 @@ function createWindow(): void {
return { action: 'deny' }
})
// 基于 electron-vite cli 的渲染器 HMR
// 加载开发环境的远程 URL 或生产环境的本地 html 文件
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
@@ -49,15 +128,29 @@ function createWindow(): void {
}
}
// Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
// 某些 API 只能在此事件发生后使用
app.whenReady().then(() => {
// 为窗口设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.electron')
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
app.whenReady().then(async () => {
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
// 在开发环境中默认按 F12 打开或关闭 DevTools
// 在生产环境中忽略 CommandOrControl + R
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
// per process. Bootstrap native once here so any later Wcdb4Client instance
// reuses the already-initialized library and skips wcdb_init.
try {
bootstrapWcdbNative()
console.log('[WCDB4] bootstrap complete at whenReady top')
} catch (bootstrapError) {
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
}
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
electronApp.setAppUserModelId('com.wechatexplorer.app')
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
@@ -65,154 +158,238 @@ app.whenReady().then(() => {
// IPC test
ipcMain.on('ping', () => console.log('pong'))
ipcMain.handle('db:init', (_, key: string) => {
try {
wechatDb = new WechatDb(key)
return true
} catch (error) {
console.error('Failed to init DB:', error)
return false
ipcMain.handle('db:init', async (_, key: string) => {
if (dbInitInFlight) return dbInitInFlight
dbInitInFlight = (async () => {
try {
const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const settings = loadSettings()
if (
chat.isReady() &&
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
(!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
) {
console.log('[WCDB4] db:init reuse current connection')
return { success: true, monitoring: true }
}
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
saveSettings({ ...settings, dbRoot: resolvedRoot })
}
chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client()
voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client)
const monitoring = wcdb4Client.startMonitor((type, json) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
imageDecryptService = null
return { success: true, monitoring }
} catch (error) {
console.error('Failed to init DB:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
} finally {
dbInitInFlight = null
}
})()
return dbInitInFlight
})
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
ipcMain.handle('key:getEnvironment', async () => {
const storage = await databaseKeyStore.getStatus()
const self = chat.getSelfAccountInfo()
return {
platform: process.platform,
autoDetectSupported: process.platform === 'win32',
wechatRunning: await isWindowsWechatRunning(),
accountIdentified: Boolean(self?.wxid),
dbConnected: chat.isReady(),
encryptionAvailable: storage.encryptionAvailable
}
})
ipcMain.handle('db:getContacts', (_, filter?: string) => {
if (!wechatDb) return []
ipcMain.handle('key:readClipboardDbKey', () => {
try {
return { success: true, value: clipboard.readText().trim() }
} catch {
return { success: false, error: '无法读取剪贴板' }
}
})
const contacts: Contact[] = []
const groupContacts = wechatDb.getAllGroupContacts()
const userList = wechatDb.getUserList(filter)
const existingMd5s = new Set<string>()
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(clipboardKey)
})
// 1. 处理普通联系人
for (const user of userList) {
const md5 = wechatDb.md5(user.m_nsUsrName)
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5: md5,
type: 'user'
})
ipcMain.handle('key:saveDbKey', async (_, key: string) =>
databaseKeyStore.save(String(key || ''))
)
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => {
const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
}
const result =
process.platform === 'win32'
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
: await keyServiceMac.autoGetDbKey(onStatus)
if (!result.success || !result.key) return result
if (options?.save === false) return result
const saved = await databaseKeyStore.save(result.key)
return {
...result,
saved: saved.success,
warning: saved.success ? undefined : saved.error
}
})
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
const settings = loadSettings()
const self = chat.getSelfAccountInfo()
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
const wxid = self?.wxid
const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
}
const result =
process.platform === 'win32'
? await keyServiceWin.autoGetImageKeyByMemoryScan(accountRoot, onStatus)
: await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid)
if (!result.success || !result.aesKey) return result
if (process.platform === 'win32') {
onStatus('发现候选密钥,图片模板验证通过')
}
// 2. 处理聊天表
const chatTables = wechatDb.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
const imageXorKey = `0x${Number(result.xorKey ?? 0x40)
.toString(16)
.toUpperCase()
.padStart(2, '0')}`
const verified = result.verified ?? process.platform === 'win32'
if (options?.save === false) {
return { ...result, verified, imageXorKey, imageAesKey: result.aesKey }
}
const saved = imageKeyConfigService.save({
resourceRoot: accountRoot,
xorKey: imageXorKey,
aesKey: result.aesKey
})
if (saved.success) imageDecryptService = null
return {
...result,
success: saved.success,
error: saved.success ? undefined : saved.error,
verified,
imageXorKey,
imageAesKey: result.aesKey,
settings: imageKeyConfigService.getLegacySettingsView()
}
})
if (!existingMd5s.has(md5)) {
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5: md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5: md5,
type: 'user'
})
}
}
ipcMain.handle('image:getConfig', () => imageKeyConfigService.getConfig())
ipcMain.handle('image:getStatus', async () =>
inspectImageDecryptionStatus(imageKeyConfigService.getConfig())
)
ipcMain.handle('image:saveConfig', (_, request: SaveImageKeyRequest) => {
const result = imageKeyConfigService.save(request)
if (result.success) imageDecryptService = null
return result
})
ipcMain.handle('image:testConfig', (_, request: TestImageDecryptionRequest) =>
testImageDecryption(request)
)
ipcMain.handle('image:clearConfig', () => {
const result = imageKeyConfigService.clear()
if (result.success) imageDecryptService = null
return result
})
ipcMain.handle('db:getBootstrapCache', () => {
if (!chat.isReady()) return null
return getBootstrapCache(chat.getCurrentAccountRoot())
})
ipcMain.handle(
'db:getCachedMessages',
(_, userMd5: string, startTime?: number, endTime?: number) => {
if (!chat.isReady()) return []
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
}
)
ipcMain.handle('db:getContacts', (_, filter?: string) => {
const accountRoot = chat.getCurrentAccountRoot()
const contacts = accountRoot
? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter))
: chat.listContacts(filter)
if (!filter && chat.isReady() && accountRoot) {
saveBootstrapContacts(accountRoot, contacts)
}
return contacts
})
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => {
if (!wechatDb) return []
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
const groupMembers = wechatDb.getAllGroupMembers()
return rawMessages.map((msg: WechatMessage) => {
const msgType = parseInt(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
let content = msg.msgContent
let img = ''
let name = ''
// 检查内容是否以 wxid 开头并包含冒号
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
if (content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialWxid = content.substring(0, colonIndex)
if (potentialWxid.startsWith('wxid_')) {
// 尝试获取头像
if (wechatDb) {
const member = wechatDb.getGroupMember(potentialWxid)
if (member) {
img = member.m_nsHeadImgUrl
}
}
if (groupMembers[potentialWxid]) {
const nickname = groupMembers[potentialWxid]
name = nickname
content = content.substring(colonIndex + 1) // +1 to skip the colon
}
}
}
}
return {
id: msg.mesLocalID || Math.random().toString(),
from: msg.mesDes === 1 ? 'user' : 'assistant', // 1 通常是接收到的,0 是发送的?需要验证。Swift 说:[1: "user", 0: "assistant"]
type: MSG_TYPE_DICT[msgType] || msg.messageType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content: content,
img: img,
name: name
}
})
})
ipcMain.handle('db:search', (_, keyword: string) => {
if (!wechatDb) return null
return wechatDb.searchAllMessages(keyword)
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => {
const avatars = chat.getContactAvatars(usernames)
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
return avatars
})
ipcMain.handle(
'ai:chat',
async (
_,
messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string }
) => {
// @ts-ignore: vite env
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
const model = options?.model || 'deepseek-chat'
if (!apiKey) {
return { success: false, error: '未配置 DeepSeek API Key' }
}
// 动态导入以避免如果未安装或初始类型缺失的问题
const { OpenAI } = await import('openai')
const openai = new OpenAI({
baseURL: 'https://api.deepseek.com',
apiKey: apiKey
})
try {
const completion = await openai.chat.completions.create({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages: messages as any,
model: model
})
return { success: true, data: completion.choices[0].message.content }
} catch (error: unknown) {
console.error('DeepSeek API Error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: errorMessage }
'db:getMessages',
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
const messages = chat.listMessages(userMd5, startTime, endTime, options)
if (chat.isReady()) {
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
}
return messages
}
)
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle(
'ai:chat',
async (_, messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
aiProviderService.chat(messages, options)
)
ipcMain.handle('ai:listProviders', () => aiProviderService.list())
ipcMain.handle('ai:getRuntimeConfig', () => aiProviderService.getRuntimeConfig())
ipcMain.handle('ai:saveProvider', (_, provider: AIProviderConfig) =>
aiProviderService.save(provider)
)
ipcMain.handle('ai:deleteProvider', (_, providerId: string) =>
aiProviderService.delete(providerId)
)
ipcMain.handle('ai:setDefaultProvider', (_, providerId: string) =>
aiProviderService.setDefault(providerId)
)
ipcMain.handle('ai:testProvider', (_, providerId: string) => aiProviderService.test(providerId))
ipcMain.handle('ai:testVision', (_, request: AIVisionTestRequest) =>
aiProviderService.testVision(request)
)
ipcMain.handle('ai:migrateLegacy', (_, config: LegacyAIConfig) =>
aiProviderService.migrateLegacy(config)
)
ipcMain.handle('copy-image', async (_, base64String) => {
try {
const image = nativeImage.createFromDataURL(base64String)
@@ -223,20 +400,296 @@ app.whenReady().then(() => {
}
})
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request)
})
ipcMain.handle('report:listGenerated', async () => {
return listGeneratedReports()
})
ipcMain.handle('report:saveGenerated', async (_, request: SaveGeneratedReportRequest) => {
return saveGeneratedReport(request)
})
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
return deleteGeneratedReport(reportId)
})
ipcMain.handle('report:reveal', async (_, filePath: string) => {
try {
shell.showItemInFolder(filePath)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle(
'db:getVoiceData',
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
if (!voiceService) {
return { success: false, error: 'VoiceService 未初始化' }
}
return voiceService.resolveVoice(sessionId, localId, createTime, svrId)
}
)
ipcMain.handle('db:parseMessage', async (_, content: string, messageType: number) => {
return parseMessageContent(content, messageType)
})
ipcMain.handle(
'db:getImage',
async (
_,
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
_sessionId?: string,
options?: { force?: boolean }
) => {
void _sessionId
if (!imageDecryptService) {
const { xorKey, aesKey } = getConfiguredImageKeys()
if (!aesKey) {
return { success: false, error: '未配置图片解密密钥' }
}
imageDecryptService = new ImageDecryptService(
xorKey,
aesKey,
chat.getChatDb()?.getWcdb4Client()
)
}
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true
let filePath = force
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
: null
if (!filePath) {
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
allowThumbnail: true
})
}
if (!filePath) {
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
}
const base64 = imageDecryptService.decryptImageToBase64(filePath)
if (!base64) {
return { success: false, error: '图片解密失败' }
}
return {
success: true,
data: base64,
isThumb: imageDecryptService.isThumbnailFile(filePath),
filePath
}
}
)
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
if (!stickerService) {
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
}
return stickerService.resolveSticker(cdnUrl, md5)
})
// -------- Settings & API service --------
ipcMain.handle('settings:get', () => ({
settings: imageKeyConfigService.getLegacySettingsView(),
settingsPath: getSettingsPath()
}))
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
const before = loadSettings()
const current = imageKeyConfigService.getConfig()
const resourceRoot = patch.imageKeyRoot ?? before.imageKeyRoot
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
if (includesImageKey) {
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
else imageKeyConfigService.clear()
imageDecryptService = null
} else {
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
}
return {
settings: imageKeyConfigService.getLegacySettingsView(),
settingsPath: getSettingsPath()
}
})
ipcMain.handle('settings:getSelf', () => {
const info = chat.getSelfAccountInfo()
if (!info) return { ready: false }
if (chat.isReady()) saveBootstrapSelf(chat.getCurrentAccountRoot(), info)
return { ready: true, info }
})
ipcMain.handle('db:testConnection', (_, key: string, accountRoot?: string) => {
return chat.testConnection(key, accountRoot)
})
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
const ok = chat.reopenWithRoot(accountRoot)
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
const info = chat.getSelfAccountInfo()
return { success: true, info }
})
ipcMain.handle('settings:selectDbRoot', async (event) => {
const result = await dialog.showOpenDialog(BrowserWindow.fromWebContents(event.sender)!, {
title: '选择微信数据库目录',
defaultPath: loadSettings().dbRoot || undefined,
properties: ['openDirectory']
})
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
ipcMain.handle('settings:openAccountRoot', async () => {
const accountRoot = chat.getCurrentAccountRoot()
if (!accountRoot) return { success: false, error: '当前没有可打开的账号目录' }
const error = await shell.openPath(accountRoot)
return error ? { success: false, error } : { success: true }
})
ipcMain.handle('db:disconnect', () => {
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
chat.setChatDb(null)
return { success: true }
})
ipcMain.handle('api:getStatus', () => apiServer.getState())
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
const settings = loadSettings()
const target = {
host: host || settings.apiHost,
port: port || settings.apiPort
}
if (host || port) saveSettings({ ...settings, ...target })
return apiServer.start(target.host, target.port)
})
ipcMain.handle('api:stop', async () => apiServer.stop())
ipcMain.handle('api:toggle', async (_, enabled: boolean) => {
const settings = saveSettings({ ...loadSettings(), apiEnabled: enabled })
if (enabled) {
return apiServer.start(settings.apiHost, settings.apiPort)
}
return apiServer.stop()
})
ipcMain.handle('api:skillStatus', () => skillResourceService.getStatus())
ipcMain.handle('api:readSkill', () => skillResourceService.read())
ipcMain.handle('api:revealSkill', () => skillResourceService.reveal())
ipcMain.handle('api:openSkillGithub', () => skillResourceService.openGithub())
ipcMain.handle('api:testLocalRequest', (_, request) => testLocalApiRequest(request))
ipcMain.handle('api:copyText', (_, text: unknown) => {
if (typeof text !== 'string' || text.length > 1024 * 1024) {
return { success: false, error: '复制内容无效或过大' }
}
try {
clipboard.writeText(text)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
createWindow()
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
const settings = loadSettings()
if (settings.apiEnabled) {
await apiServer.start(settings.apiHost, settings.apiPort)
}
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
}
app.on('activate', function () {
// macOS 上,当点击 dock 图标且没有其他窗口打开时,
// 通常会在应用程序中重新创建一个窗口。
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 当所有窗口关闭时退出,除了 macOS。在那里,
// 应用程序及其菜单栏通常会保持活动状态,直到用户
// 显式使用 Cmd + Q 退出。
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
app.on('window-all-closed', () => {
if (TRAY_MODE) return
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', async () => {
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
if (tray) {
tray.destroy()
tray = null
}
})
function showMainWindow(): void {
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
const wins = BrowserWindow.getAllWindows()
if (wins.length === 0) {
createWindow()
return
}
const win = wins[0]
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
{
label: '打开主窗口',
click: () => showMainWindow()
},
{
label: 'API 状态',
click: () => showMainWindow()
},
{ type: 'separator' },
{
label: '退出 WechatExplorer',
click: () => {
tray?.destroy()
tray = null
app.quit()
}
}
])
}
function setupTray(): void {
if (tray) return
try {
const image = nativeImage.createFromPath(appIconPath)
const traySize = process.platform === 'darwin' ? 20 : 24
const trayImage = image.isEmpty()
? nativeImage.createEmpty()
: image.resize({ width: traySize, height: traySize, quality: 'best' })
tray = new Tray(trayImage)
tray.setToolTip('WechatExplorer')
tray.setContextMenu(buildTrayMenu())
tray.on('click', () => showMainWindow())
} catch (error) {
console.warn('[Tray] Failed to create tray:', error)
}
}
+444
View File
@@ -0,0 +1,444 @@
import { app } from 'electron'
import { execFile } from 'child_process'
import fs from 'fs-extra'
import path from 'path'
import { promisify } from 'util'
import { isValidDatabaseKey } from './database-key-store'
import crypto from 'crypto'
import { findResource, getResourceCandidates } from './resource-paths'
const execFileAsync = promisify(execFile)
export interface DatabaseKeyResult {
success: boolean
key?: string
error?: string
code?: string
}
export interface ImageKeyResult {
success: boolean
xorKey?: number
aesKey?: string
verified?: boolean
error?: string
}
export class KeyServiceMac {
private getHelperPath(): string {
const helperPath = findResource('xkey_helper')
if (!helperPath) {
throw new Error(
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join('')}`
)
}
return helperPath
}
private async isSipEnabled(): Promise<boolean> {
try {
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
return stdout.toLowerCase().includes('enabled')
} catch {
return false
}
}
private async getWeChatPid(): Promise<number> {
const commands: [string, string[]][] = [
['/usr/bin/pgrep', ['-x', 'WeChat']],
['/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']]
]
for (const [command, args] of commands) {
try {
const { stdout } = await execFileAsync(command, args)
const pids = stdout
.split(/\r?\n/)
.map((value) => Number.parseInt(value.trim(), 10))
.filter((value) => Number.isFinite(value) && value > 0)
if (pids.length) return Math.max(...pids)
} catch {
// Try the next process lookup strategy.
}
}
throw new Error('未找到微信主进程,请先启动并登录微信')
}
private parseHelperOutput(output: string): DatabaseKeyResult {
const payloads: Record<string, unknown>[] = []
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
try {
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
} catch {
// Ignore helper progress that is not JSON.
}
}
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
if (!isValidDatabaseKey(rawKey)) {
const errorPayload = payloads.find((item) => typeof item.result === 'string')
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
const code = parsedError?.[1]?.toUpperCase()
const detail = parsedError?.[2]?.trim() || ''
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
return {
success: false,
code,
error:
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
'建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
}
}
if (code === 'SCAN_FAILED') {
return {
success: false,
code,
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
}
}
return {
success: false,
code,
error: rawError || '密钥工具未返回有效的 64 位密钥'
}
}
return { success: true, key: rawKey }
}
async autoGetDbKey(
onStatus?: (message: string) => void,
timeoutMs = 60_000
): Promise<DatabaseKeyResult> {
if (process.platform !== 'darwin') {
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
}
if (await this.isSipEnabled()) {
return {
success: false,
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
}
}
try {
onStatus?.('正在查找微信进程...')
const pid = await this.getWeChatPid()
const helperPath = this.getHelperPath()
const waitMs = Math.max(30_000, timeoutMs)
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
onStatus?.('正在请求管理员授权...')
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
`set timeoutSec to ${timeoutSeconds}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'end timeout',
'return "OK::" & outText',
'on error errMsg number errNum',
'return "ERR::" & errNum & "::" & errMsg',
'end try'
]
onStatus?.('授权后请保持微信登录界面 并点击登录微信...')
const { stdout } = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap((line) => ['-e', line]),
{ timeout: waitMs + 20_000 }
)
const output = String(stdout).trim()
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
if (output.startsWith('ERR::')) {
return {
success: false,
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
}
}
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async autoGetImageKey(
accountPath?: string,
onStatus?: (message: string) => void,
wxid?: string
): Promise<ImageKeyResult> {
try {
onStatus?.('正在从缓存目录扫描图片密钥...')
const codes = this.collectKvcommCodes(accountPath)
if (codes.length === 0) {
return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' }
}
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
for (const candidateAccountPath of accountPathCandidates) {
if (!fs.existsSync(candidateAccountPath)) continue
const template = this.findTemplateData(candidateAccountPath, 32)
if (!template.ciphertext) continue
const orderedWxids: string[] = []
this.pushAccountIdCandidates(orderedWxids, path.basename(candidateAccountPath))
for (const candidate of wxidCandidates)
this.pushAccountIdCandidates(orderedWxids, candidate)
onStatus?.(`正在校验候选 wxid${orderedWxids.length} 个)...`)
for (const candidateWxid of orderedWxids) {
for (const code of codes) {
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
onStatus?.(`图片密钥获取成功 (wxid: ${candidateWxid})`)
return { success: true, xorKey, aesKey, verified: true }
}
}
}
const fallbackWxid = wxidCandidates[0]
const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onStatus?.(`图片密钥已计算 (wxid: ${fallbackWxid})`)
return { success: true, xorKey, aesKey, verified: false }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
private collectKvcommCodes(accountPath?: string): number[] {
const codeSet = new Set<number>()
const pattern = /^key_(\d+)_.+\.statistic$/i
for (const kvcommDir of this.getKvcommCandidates(accountPath)) {
if (!fs.existsSync(kvcommDir)) continue
try {
for (const file of fs.readdirSync(kvcommDir)) {
const match = file.match(pattern)
if (!match) continue
const code = Number(match[1])
if (Number.isFinite(code) && code > 0 && code <= 0xffffffff) codeSet.add(code)
}
} catch {
// Try the next candidate.
}
}
return Array.from(codeSet)
}
private getKvcommCandidates(accountPath?: string): string[] {
const home = app.getPath('home')
const candidates = new Set<string>([
path.join(
home,
'Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm'
),
path.join(
home,
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm'
),
path.join(
home,
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/net/kvcomm'
),
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat/net/kvcomm')
])
const normalized = String(accountPath || '')
.replace(/\\/g, '/')
.replace(/\/+$/, '')
const marker = '/xwechat_files'
const markerIndex = normalized.indexOf(marker)
if (markerIndex >= 0) {
candidates.add(`${normalized.slice(0, markerIndex)}/app_data/net/kvcomm`)
}
const newPathMatch = normalized.match(
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/
)
if (newPathMatch) {
candidates.add(`${newPathMatch[1]}/net/kvcomm`)
candidates.add(`${newPathMatch[1]}/xwechat/net/kvcomm`)
}
return Array.from(candidates)
}
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
const candidates: string[] = []
this.pushAccountIdCandidates(candidates, wxidParam)
const normalized = String(accountPath || '')
.replace(/\\/g, '/')
.replace(/\/+$/, '')
if (normalized) {
this.pushAccountIdCandidates(candidates, path.basename(normalized))
const root = this.resolveXwechatRootFromPath(normalized)
if (root && fs.existsSync(root)) {
try {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const entryPath = path.join(root, entry.name)
if (this.isAccountDirPath(entryPath))
this.pushAccountIdCandidates(candidates, entry.name)
}
} catch {
// Ignore unreadable directories.
}
}
}
if (candidates.length === 0) candidates.push('unknown')
return candidates
}
private collectAccountPathCandidates(accountPath?: string): string[] {
const candidates: string[] = []
const push = (value?: string): void => {
const normalized = String(value || '').trim()
if (normalized && !candidates.includes(normalized)) candidates.push(normalized)
}
push(accountPath)
const root = this.resolveXwechatRootFromPath(accountPath)
if (root && fs.existsSync(root)) {
try {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const entryPath = path.join(root, entry.name)
if (this.isAccountDirPath(entryPath) && this.isReasonableAccountId(entry.name))
push(entryPath)
}
} catch {
// Ignore unreadable directories.
}
}
return candidates
}
private resolveXwechatRootFromPath(accountPath?: string): string | null {
const normalized = String(accountPath || '')
.replace(/\\/g, '/')
.replace(/\/+$/, '')
if (!normalized) return null
const marker = '/xwechat_files'
const markerIndex = normalized.indexOf(marker)
if (markerIndex >= 0) return normalized.slice(0, markerIndex + marker.length)
const newPathMatch = normalized.match(
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/
)
return newPathMatch ? newPathMatch[1] : null
}
private isAccountDirPath(entryPath: string): boolean {
return (
fs.existsSync(path.join(entryPath, 'db_storage')) ||
fs.existsSync(path.join(entryPath, 'msg')) ||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image')) ||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image2'))
)
}
private pushAccountIdCandidates(candidates: string[], value?: string): void {
const raw = String(value || '').trim()
if (!this.isReasonableAccountId(raw)) return
for (const candidate of [raw, this.normalizeAccountId(raw)]) {
if (candidate && !candidates.includes(candidate) && this.isReasonableAccountId(candidate)) {
candidates.push(candidate)
}
}
}
private normalizeAccountId(value: string): string {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
private isReasonableAccountId(value: string): boolean {
const lowered = String(value || '')
.trim()
.toLowerCase()
if (!lowered || lowered.includes('/') || lowered.includes('\\')) return false
return !['xwechat_files', 'all_users', 'backup', 'wmpf', 'app_data'].includes(lowered)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const xorKey = code & 0xff
const aesKey = crypto
.createHash('md5')
.update(`${code}${this.normalizeAccountId(wxid)}`)
.digest('hex')
.substring(0, 16)
return { xorKey, aesKey }
}
private findTemplateData(userDir: string, limit = 32): { ciphertext: Buffer | null } {
const magic = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
const files: string[] = []
const collect = (dir: string): void => {
if (files.length >= limit) return
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (files.length >= limit) break
const full = path.join(dir, entry.name)
if (entry.isDirectory()) collect(full)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) files.push(full)
}
} catch {
// Ignore unreadable directories.
}
}
collect(userDir)
files.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs
} catch {
return 0
}
})
for (const file of files) {
try {
const data = fs.readFileSync(file)
if (data.length >= 0x1f && data.subarray(0, 6).equals(magic)) {
return { ciphertext: data.subarray(0x0f, 0x1f) }
}
} catch {
// Try the next file.
}
}
return { ciphertext: null }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv(
'aes-128-ecb',
Buffer.from(aesKey, 'ascii').subarray(0, 16),
null
)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xff && dec[1] === 0xd8 && dec[2] === 0xff) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4e && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
}
File diff suppressed because it is too large Load Diff
+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
}
+37
View File
@@ -0,0 +1,37 @@
import fs from 'fs'
import path from 'path'
function prependPath(values: string[]): void {
if (process.platform !== 'win32') return
const existing = process.env.PATH || ''
const next = Array.from(new Set(values.filter(Boolean))).join(path.delimiter)
process.env.PATH = next ? `${next}${path.delimiter}${existing}` : existing
process.env.Path = process.env.PATH
}
try {
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const resourceRoots = [
path.join(process.cwd(), 'resources'),
path.join(process.cwd(), 'resources', 'resources'),
path.join(process.resourcesPath || '', 'resources'),
process.resourcesPath || ''
].filter((value, index, list) => value && list.indexOf(value) === index && fs.existsSync(value))
const resourcesRoot = resourceRoots[0] || path.join(process.cwd(), 'resources')
const dllDirs = resourceRoots.flatMap((root) => [
root,
path.join(root, 'wcdb', 'win32', archDir),
path.join(root, 'wcdb', 'win32', 'x64'),
path.join(root, 'key', 'win32', archDir),
path.join(root, 'key', 'win32', 'x64'),
path.join(root, 'runtime', 'win32')
])
process.env.WCDB_RESOURCES_PATH = process.env.WCDB_RESOURCES_PATH || resourcesRoot
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
} catch (error) {
console.error('[WechatExplorer] failed to enforce local DLL priority:', error)
}
+238
View File
@@ -0,0 +1,238 @@
import { app, nativeImage } from 'electron'
import { promises as fs } from 'fs'
import path from 'path'
import type {
GeneratedReportRecord,
DeleteGeneratedReportResult,
ReportAssetStatus,
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
} from '../shared/report-history'
const REPORTS_DIR = 'reports'
const getReportsRoot = (): string => path.join(app.getPath('userData'), REPORTS_DIR)
const pad2 = (value: number): string => String(value).padStart(2, '0')
const safeSegment = (value: string): string =>
value
.trim()
// Strip Windows-reserved filename characters and ASCII control chars.
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 48) || 'report'
const parseDataUrl = (dataUrl: string): Buffer | null => {
const match = /^data:image\/png;base64,(.+)$/i.exec(dataUrl)
if (!match) return null
return Buffer.from(match[1], 'base64')
}
const exists = async (filePath?: string): Promise<boolean> => {
if (!filePath) return false
try {
const stat = await fs.stat(filePath)
return stat.isFile()
} catch {
return false
}
}
const fileStatus = async (filePath?: string): Promise<ReportAssetStatus> =>
(await exists(filePath)) ? 'ready' : 'missing'
const readPngAsDataUrl = async (filePath?: string): Promise<string | undefined> => {
if (!filePath || !(await exists(filePath))) return undefined
const content = await fs.readFile(filePath)
return `data:image/png;base64,${content.toString('base64')}`
}
const readPngSize = async (
filePath?: string
): Promise<{ width: number; height: number } | undefined> => {
if (!filePath || !(await exists(filePath))) return undefined
const image = nativeImage.createFromPath(filePath)
if (image.isEmpty()) return undefined
const size = image.getSize()
return size.width && size.height ? size : undefined
}
const readFileSize = async (filePath?: string): Promise<number | undefined> => {
if (!filePath) return undefined
try {
const stat = await fs.stat(filePath)
return stat.isFile() ? stat.size : undefined
} catch {
return undefined
}
}
const normalizeRecord = async (
record: GeneratedReportRecord,
jsonPath: string
): Promise<GeneratedReportRecord> => {
const htmlStatus = await fileStatus(record.htmlPath)
const pngStatus = await fileStatus(record.pngPath)
return {
...record,
jsonPath,
htmlStatus,
pngStatus,
generatedImage: await readPngAsDataUrl(record.pngPath),
imageSize: await readPngSize(record.pngPath),
fileSize: {
html: await readFileSize(record.htmlPath),
png: await readFileSize(record.pngPath)
}
}
}
const walkJsonFiles = async (directory: string): Promise<string[]> => {
try {
const entries = await fs.readdir(directory, { withFileTypes: true })
const children = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) return walkJsonFiles(entryPath)
return entry.isFile() && entry.name.endsWith('.json') ? [entryPath] : []
})
)
return children.flat()
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}
export async function listGeneratedReports(): Promise<ReportHistoryResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
const records = await Promise.all(
jsonFiles.map(async (jsonPath) => {
try {
const content = await fs.readFile(jsonPath, 'utf8')
return normalizeRecord(JSON.parse(content) as GeneratedReportRecord, jsonPath)
} catch (error) {
console.warn(`[ReportHistory] skip invalid report record: ${jsonPath}`, error)
return null
}
})
)
const reports = records.filter((record): record is GeneratedReportRecord => Boolean(record))
reports.sort((left, right) => Date.parse(right.generatedAt) - Date.parse(left.generatedAt))
return { success: true, reports }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function saveGeneratedReport(
request: SaveGeneratedReportRequest
): Promise<SaveGeneratedReportResult> {
try {
const generatedAtDate = new Date(request.generatedAt)
const timestamp = Number.isFinite(generatedAtDate.getTime()) ? generatedAtDate : new Date()
const year = String(timestamp.getFullYear())
const month = pad2(timestamp.getMonth() + 1)
const directory = path.join(getReportsRoot(), year, month)
await fs.mkdir(directory, { recursive: true })
const id = `report_${timestamp.getTime()}_${Math.random().toString(36).slice(2, 8)}`
const baseName = `${id}_${safeSegment(request.contactName)}`
const htmlPath = path.join(directory, `${baseName}.html`)
const pngPath = path.join(directory, `${baseName}.png`)
const jsonPath = path.join(directory, `${baseName}.json`)
let savedHtmlPath: string | undefined
if (request.htmlPath && (await exists(request.htmlPath))) {
await fs.copyFile(request.htmlPath, htmlPath)
savedHtmlPath = htmlPath
}
let savedPngPath: string | undefined
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
if (imageBuffer) {
await fs.writeFile(pngPath, imageBuffer)
savedPngPath = pngPath
} else if (request.pngPath && (await exists(request.pngPath))) {
await fs.copyFile(request.pngPath, pngPath)
savedPngPath = pngPath
}
const record: GeneratedReportRecord = {
id,
contactId: request.contactId,
contactName: request.contactName,
contactAvatar: request.contactAvatar,
dateRange: request.dateRange,
messageCount: request.messageCount,
generatedAt: timestamp.toISOString(),
reportDate: `${year}-${month}-${pad2(timestamp.getDate())}`,
htmlPath: savedHtmlPath,
pngPath: savedPngPath,
jsonPath,
htmlStatus: savedHtmlPath ? 'ready' : 'missing',
pngStatus: savedPngPath ? 'ready' : 'missing',
imageSize: await readPngSize(savedPngPath),
duration: request.duration,
modelName: request.modelName,
tokenUsage: request.tokenUsage,
fileSize: {
html: await readFileSize(savedHtmlPath),
png: await readFileSize(savedPngPath)
},
generationLogs: request.generationLogs
}
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
return {
success: true,
record: {
...record,
generatedImage: savedPngPath ? await readPngAsDataUrl(savedPngPath) : undefined
}
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function deleteGeneratedReport(
reportId: string
): Promise<DeleteGeneratedReportResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) {
try {
const content = await fs.readFile(jsonPath, 'utf8')
const record = JSON.parse(content) as GeneratedReportRecord
if (record.id !== reportId) continue
const paths = [record.htmlPath, record.pngPath, jsonPath].filter(
(filePath): filePath is string => Boolean(filePath)
)
await Promise.all(
paths.map(async (filePath) => {
try {
await fs.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
})
)
return { success: true, deletedId: reportId }
} catch (error) {
console.warn(
`[ReportHistory] skip invalid report record while deleting: ${jsonPath}`,
error
)
}
}
return { success: false, error: '未找到要删除的日报记录' }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
+35
View File
@@ -0,0 +1,35 @@
import { app } from 'electron'
import { existsSync } from 'fs'
import { dirname, join } from 'path'
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)))
}
export function getResourceRoots(): string[] {
const appPath = app.getAppPath()
const appPathDir = dirname(appPath)
const execDir = dirname(process.execPath)
return unique([
process.env.WECHATEXPLORER_RESOURCES_PATH || '',
join(process.cwd(), 'resources'),
join(process.cwd(), 'resources', 'resources'),
join(process.resourcesPath || '', 'resources'),
process.resourcesPath || '',
join(appPath, 'resources'),
join(appPathDir, 'resources'),
appPathDir,
join(execDir, 'resources'),
join(dirname(execDir), 'Resources', 'resources'),
join(dirname(execDir), 'Resources')
]).filter((root) => existsSync(root))
}
export function getResourceCandidates(relativePath: string): string[] {
return unique(getResourceRoots().map((root) => join(root, relativePath)))
}
export function findResource(relativePath: string): string | null {
return getResourceCandidates(relativePath).find((candidate) => existsSync(candidate)) || null
}
+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
}
+582
View File
@@ -0,0 +1,582 @@
import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type {
AIChatRequestOptions,
AIConnectionTestResult,
AIProviderConfig,
AIProviderListResult,
AIProviderSummary,
AIRuntimeModelConfig,
AIVisionTestRequest,
AIVisionTestResult,
LegacyAIConfig
} from '../../shared/ai-provider'
import { AIProviderKeyStore } from '../ai-provider-key-store'
interface AIProviderMetadataFile {
version: 1
defaultProviderId?: string
providers: Array<Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>>
}
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
type AIMessage = { role: string; content: string | AIMessagePart[] }
type AIRequestResult = {
data: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
}
interface OpenAIResponsePayload {
error?: { message?: string }
choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
}
interface AnthropicResponsePayload {
error?: { message?: string }
content?: Array<{ type?: string; text?: string }>
usage?: { input_tokens?: number; output_tokens?: number }
}
export class AIProviderService {
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
list(): AIProviderListResult {
this.ensureEnvironmentMigration()
try {
const data = this.readMetadata()
return {
success: true,
defaultProviderId: data.defaultProviderId,
providers: data.providers.map((provider) =>
this.toSummary(provider, data.defaultProviderId)
)
}
} catch {
return { success: false, providers: [], error: 'AI Provider 配置无法读取' }
}
}
getRuntimeConfig(): AIRuntimeModelConfig {
const result = this.list()
const provider =
result.providers.find((item) => item.id === result.defaultProviderId) || result.providers[0]
const model = provider?.models.find((item) => item.id === provider.defaultModel)
return {
providerId: provider?.id,
providerName: provider?.name || '尚未配置',
model: provider?.defaultModel || '',
modelName: model?.name || provider?.defaultModel || '尚未选择模型',
configured: Boolean(
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
),
status: provider?.status || 'untested'
}
}
save(input: AIProviderConfig): AIProviderListResult {
const validationError = validateProvider(input)
if (validationError) return { success: false, providers: [], error: validationError }
const data = this.readMetadata()
const existing = data.providers.find((provider) => provider.id === input.id)
if (input.apiKey?.trim()) {
const saved = this.keyStore.save(input.id, input.apiKey.trim())
if (!saved.success) return { success: false, providers: [], error: saved.error }
} else if (needsApiKey(input) && !this.keyStore.get(input.id).key) {
return { success: false, providers: [], error: '请填写 API Key' }
}
const metadata: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> = {
id: input.id,
name: input.name.trim(),
type: input.type,
baseUrl: input.baseUrl.trim().replace(/\/+$/, ''),
auth: input.auth,
models: input.models,
defaultModel: input.defaultModel,
advanced: input.advanced,
status: existing?.status || 'untested',
lastTestedAt: existing?.lastTestedAt,
lastError: existing?.lastError
}
const index = data.providers.findIndex((provider) => provider.id === input.id)
if (index >= 0) data.providers[index] = metadata
else data.providers.push(metadata)
if (!data.defaultProviderId) data.defaultProviderId = input.id
this.writeMetadata(data)
return this.list()
}
delete(providerId: string): AIProviderListResult {
const data = this.readMetadata()
data.providers = data.providers.filter((provider) => provider.id !== providerId)
if (data.defaultProviderId === providerId) data.defaultProviderId = data.providers[0]?.id
const cleared = this.keyStore.clear(providerId)
if (!cleared.success) return { success: false, providers: [], error: cleared.error }
this.writeMetadata(data)
return this.list()
}
setDefault(providerId: string): AIProviderListResult {
const data = this.readMetadata()
if (!data.providers.some((provider) => provider.id === providerId)) {
return { success: false, providers: [], error: '供应商不存在' }
}
data.defaultProviderId = providerId
this.writeMetadata(data)
return this.list()
}
migrateLegacy(config: LegacyAIConfig): AIProviderListResult {
const data = this.readMetadata()
if (data.providers.length) return this.list()
const provider = deepSeekProvider(config.baseUrl, config.model)
if (config.apiKey?.trim()) {
const saved = this.keyStore.save(provider.id, config.apiKey.trim())
if (!saved.success) return { success: false, providers: [], error: saved.error }
}
data.providers = [stripRuntimeFields(provider)]
data.defaultProviderId = provider.id
this.writeMetadata(data)
return this.list()
}
async test(providerId: string): Promise<AIConnectionTestResult> {
const startedAt = Date.now()
try {
await this.request([{ role: 'user', content: 'Reply with OK.' }], { providerId }, true)
this.updateTestStatus(providerId, 'connected')
return { success: true, latencyMs: Date.now() - startedAt }
} catch (error) {
const message = safeAIError(error)
this.updateTestStatus(providerId, 'error', message)
return { success: false, error: message, latencyMs: Date.now() - startedAt }
}
}
async chat(
messages: Array<{ role: string; content: string }>,
options?: AIChatRequestOptions
): Promise<{
success: boolean
data?: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
error?: string
}> {
try {
return { success: true, ...(await this.request(messages, options)) }
} catch (error) {
return { success: false, error: safeAIError(error) }
}
}
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
const startedAt = Date.now()
const imageError = validateVisionImage(request.imageDataUrl)
if (imageError) return { success: false, code: 'INVALID_IMAGE', error: imageError }
if (!request.prompt.trim()) {
return { success: false, code: 'INVALID_IMAGE', error: '请填写图片识别提示词' }
}
try {
const resolved = this.resolveProvider(request)
const result = await requestProvider(resolved.provider, resolved.key, resolved.model, [
{
role: 'user',
content: [
{ type: 'text', text: request.prompt.trim() },
{ type: 'image', dataUrl: request.imageDataUrl }
]
}
])
if (!result.data.trim()) throw new Error('API 未返回识别内容')
this.markVisionCapability(resolved.provider.id, resolved.model)
const model = resolved.provider.models.find((item) => item.id === resolved.model)
return {
success: true,
providerName: resolved.provider.name,
modelId: resolved.model,
modelName: model?.name || resolved.model,
latencyMs: Date.now() - startedAt,
usage: result.usage,
answer: result.data
}
} catch (error) {
const failure = visionFailure(error)
return { success: false, ...failure, latencyMs: Date.now() - startedAt }
}
}
private async request(
messages: AIMessage[],
options?: AIChatRequestOptions,
testing = false
): Promise<{
data: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
}> {
if (options?.apiKey) return this.requestLegacy(messages, options)
const resolved = this.resolveProvider(options)
return requestProvider(resolved.provider, resolved.key, resolved.model, messages, testing)
}
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
provider: AIProviderSummary
model: string
key: string
} {
const list = this.list()
const provider =
list.providers.find((item) => item.id === options?.providerId) ||
list.providers.find((item) => item.id === list.defaultProviderId)
if (!provider) throw new Error('尚未配置 AI Provider')
const model = options?.modelId || provider.defaultModel
if (!provider.models.some((item) => item.id === model)) throw new Error('当前模型不存在')
const key = this.keyStore.get(provider.id).key || ''
if (needsApiKey(provider) && !key) throw new Error('当前供应商尚未配置 API Key')
return { provider, model, key }
}
private async requestLegacy(
messages: AIMessage[],
options: AIChatRequestOptions
): Promise<AIRequestResult> {
const provider = deepSeekProvider(options.baseURL, options.model)
return requestOpenAICompatible(
provider,
options.apiKey || '',
options.model || provider.defaultModel,
messages
)
}
private updateTestStatus(
providerId: string,
status: 'connected' | 'error',
lastError?: string
): void {
const data = this.readMetadata()
const provider = data.providers.find((item) => item.id === providerId)
if (!provider) return
provider.status = status
provider.lastTestedAt = Date.now()
provider.lastError = lastError
this.writeMetadata(data)
}
private markVisionCapability(providerId: string, modelId: string): void {
const data = this.readMetadata()
const provider = data.providers.find((item) => item.id === providerId)
const model = provider?.models.find((item) => item.id === modelId)
if (!provider || !model || model.capabilities.vision) return
model.capabilities.vision = true
this.writeMetadata(data)
}
private ensureEnvironmentMigration(): void {
const data = this.readMetadata()
if (data.providers.length) return
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim()
if (!apiKey) return
this.migrateLegacy({
apiKey,
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
model: String(import.meta.env.VITE_AI_MODEL || '')
})
}
private toSummary(
provider: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
defaultProviderId?: string
): AIProviderSummary {
return {
...provider,
hasApiKey: Boolean(this.keyStore.get(provider.id).key),
isDefault: provider.id === defaultProviderId
}
}
private readMetadata(): AIProviderMetadataFile {
const filePath = this.metadataPath
if (!fs.existsSync(filePath)) return { version: 1, providers: [] }
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
if (data.version !== 1 || !Array.isArray(data.providers))
throw new Error('invalid provider metadata')
return data
}
private writeMetadata(data: AIProviderMetadataFile): void {
fs.ensureDirSync(path.dirname(this.metadataPath))
fs.writeJsonSync(this.metadataPath, data, { spaces: 2 })
}
private get metadataPath(): string {
return path.join(app.getPath('userData'), 'ai-providers.json')
}
}
function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
const modelId = model?.trim() || 'deepseek-chat'
return {
id: 'deepseek',
name: 'DeepSeek',
type: 'openai-compatible',
baseUrl: baseUrl?.trim() || 'https://api.deepseek.com',
auth: { type: 'bearer' },
models: [
{
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
id: modelId,
capabilities: { chat: true, vision: false, longContext: true }
}
],
defaultModel: modelId,
advanced: { timeoutMs: 120_000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} },
hasApiKey: false,
isDefault: true,
status: 'untested'
}
}
function stripRuntimeFields(
provider: AIProviderSummary
): Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> {
return {
id: provider.id,
name: provider.name,
type: provider.type,
baseUrl: provider.baseUrl,
auth: provider.auth,
models: provider.models,
defaultModel: provider.defaultModel,
advanced: provider.advanced,
status: provider.status,
lastTestedAt: provider.lastTestedAt,
lastError: provider.lastError
}
}
function needsApiKey(provider: Pick<AIProviderConfig, 'type' | 'auth'>): boolean {
return provider.type !== 'ollama' && provider.auth.type !== 'none'
}
function validateProvider(provider: AIProviderConfig): string | undefined {
if (!provider.id.trim() || !/^[a-z0-9][a-z0-9-_]*$/i.test(provider.id))
return '供应商 ID 格式不正确'
if (!provider.name.trim()) return '供应商名称不能为空'
if (!provider.baseUrl.trim()) return 'Base URL 不能为空'
if (!provider.models.length) return '请至少添加一个模型'
if (provider.models.some((model) => !model.name.trim() || !model.id.trim()))
return '模型名称和 ID 不能为空'
if (!provider.models.some((model) => model.id === provider.defaultModel))
return '默认模型不在模型列表中'
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
return '请填写自定义认证字段'
return undefined
}
function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<string, string> {
const headers: Record<string, string> = {
'content-type': 'application/json',
...provider.advanced.extraHeaders
}
if (!apiKey || provider.auth.type === 'none') return headers
if (provider.auth.type === 'bearer') headers.authorization = `Bearer ${apiKey}`
else if (provider.auth.type === 'x-api-key') headers['x-api-key'] = apiKey
else headers[provider.auth.headerName || 'authorization'] = apiKey
return headers
}
function requestProvider(
provider: AIProviderSummary,
apiKey: string,
model: string,
messages: AIMessage[],
testing = false
): Promise<AIRequestResult> {
return provider.type === 'anthropic-messages'
? requestAnthropic(provider, apiKey, model, messages, testing)
: requestOpenAICompatible(provider, apiKey, model, messages, testing)
}
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages.map((message) => ({
role: message.role,
content:
typeof message.content === 'string'
? message.content
: message.content.map((part) =>
part.type === 'text'
? { type: 'text', text: part.text }
: { type: 'image_url', image_url: { url: part.dataUrl } }
)
}))
}
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages
.filter((message) => message.role !== 'system')
.map((message) => ({
role: message.role,
content:
typeof message.content === 'string'
? message.content
: message.content.map((part) => {
if (part.type === 'text') return { type: 'text', text: part.text }
const image = parseVisionImage(part.dataUrl)
return {
type: 'image',
source: { type: 'base64', media_type: image.mimeType, data: image.base64 }
}
})
}))
}
async function requestOpenAICompatible(
provider: AIProviderSummary,
apiKey: string,
model: string,
messages: AIMessage[],
testing = false
): Promise<AIRequestResult> {
const endpoint = provider.baseUrl.endsWith('/chat/completions')
? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
const response = await fetchWithTimeout(
endpoint,
{
method: 'POST',
headers: buildHeaders(provider, apiKey),
body: JSON.stringify({
model,
messages: toOpenAIMessages(messages),
temperature: provider.advanced.temperature,
max_tokens: testing ? 8 : provider.advanced.maxTokens
})
},
provider.advanced.timeoutMs
)
const payload = (await response.json()) as OpenAIResponsePayload
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
return {
data: String(payload.choices?.[0]?.message?.content || ''),
usage: payload.usage
? {
input: payload.usage.prompt_tokens,
output: payload.usage.completion_tokens,
total: payload.usage.total_tokens,
estimated: false
}
: undefined
}
}
async function requestAnthropic(
provider: AIProviderSummary,
apiKey: string,
model: string,
messages: AIMessage[],
testing = false
): Promise<AIRequestResult> {
const system = messages
.filter((message) => message.role === 'system')
.map((message) =>
typeof message.content === 'string'
? message.content
: message.content
.filter((part) => part.type === 'text')
.map((part) => (part.type === 'text' ? part.text : ''))
.join('\n')
)
.join('\n\n')
const anthropicMessages = toAnthropicMessages(messages)
const headers = buildHeaders(provider, apiKey)
if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01'
const endpoint = provider.baseUrl.endsWith('/messages')
? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/messages`
const response = await fetchWithTimeout(
endpoint,
{
method: 'POST',
headers,
body: JSON.stringify({
model,
system: system || undefined,
messages: anthropicMessages,
temperature: provider.advanced.temperature,
max_tokens: testing ? 8 : provider.advanced.maxTokens || 4096
})
},
provider.advanced.timeoutMs
)
const payload = (await response.json()) as AnthropicResponsePayload
if (!response.ok)
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
return {
data: Array.isArray(payload.content)
? payload.content
.filter((item) => item.type === 'text')
.map((item) => item.text || '')
.join('\n')
: '',
usage: payload.usage
? {
input: payload.usage.input_tokens,
output: payload.usage.output_tokens,
total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
estimated: false
}
: undefined
}
}
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs || 120_000))
try {
return await fetch(url, { ...init, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
function safeAIError(error: unknown): string {
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
const message = error instanceof Error ? error.message : String(error)
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
}
function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } {
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP')
const bytes = Buffer.byteLength(match[2], 'base64')
return { mimeType: match[1].toLowerCase(), base64: match[2], bytes }
}
function validateVisionImage(dataUrl: string): string | undefined {
try {
const image = parseVisionImage(dataUrl)
if (!image.bytes) return '图片内容为空'
if (image.bytes > 10 * 1024 * 1024) return '图片不能超过 10 MB'
return undefined
} catch (error) {
return error instanceof Error ? error.message : '图片无法读取'
}
}
function visionFailure(error: unknown): {
code: 'VISION_UNSUPPORTED' | 'API_ERROR'
error: string
} {
const message = safeAIError(error)
const unsupported =
/vision|multimodal|image[_ ]url|image input|image.*support|support.*image|图片.*不支持|不支持.*图片/i.test(
message
)
return unsupported
? { code: 'VISION_UNSUPPORTED', error: '当前模型不支持图片理解' }
: { code: 'API_ERROR', error: message || 'API 返回错误' }
}
+246
View File
@@ -0,0 +1,246 @@
import { app } from 'electron'
import crypto from 'crypto'
import fs from 'fs-extra'
import path from 'path'
import type { Contact, Message } from '../../shared/types'
export interface CachedSelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface CachedMessageBucket {
updatedAt: number
startTime?: number
endTime?: number
items: Message[]
}
interface BootstrapCacheFile {
version: 1
platform: NodeJS.Platform
accountRoot: string
updatedAt: number
self?: CachedSelfInfo
contacts?: Contact[]
messages?: Record<string, CachedMessageBucket>
}
const CACHE_VERSION = 1
const MAX_MESSAGE_BUCKETS = 24
const MAX_MESSAGES_PER_BUCKET = 1200
function normalizeRoot(accountRoot?: string): string {
return String(accountRoot || '').trim()
}
function getCacheFile(accountRoot?: string): string {
const normalizedRoot = normalizeRoot(accountRoot) || 'default'
const hash = crypto
.createHash('sha1')
.update(`${process.platform}:${normalizedRoot}`)
.digest('hex')
.slice(0, 16)
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
}
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getCacheFile(normalizedRoot)
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
return {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
}
} catch (error) {
console.warn('[BootstrapCache] read failed:', error)
return null
}
}
function writeCacheFile(cache: BootstrapCacheFile): void {
try {
const file = getCacheFile(cache.accountRoot)
fs.ensureDirSync(path.dirname(file))
fs.writeJsonSync(file, cache, { spaces: 2 })
} catch (error) {
console.warn('[BootstrapCache] write failed:', error)
}
}
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
return (
readCacheFile(normalizedRoot) || {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {}
}
)
}
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
}
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
const entries = Object.entries(messages)
if (entries.length <= MAX_MESSAGE_BUCKETS) return
entries
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
.slice(MAX_MESSAGE_BUCKETS)
.forEach(([key]) => {
delete messages[key]
})
}
export function getBootstrapCache(accountRoot?: string): {
self?: CachedSelfInfo
contacts: Contact[]
updatedAt: number
} | null {
const cache = readCacheFile(accountRoot)
if (!cache) return null
return {
self: cache.self,
contacts: cache.contacts || [],
updatedAt: cache.updatedAt
}
}
function isRawContactName(contact: Contact): boolean {
const name = String(contact.m_nsNickName || '').trim()
const username = String(contact.m_nsUsrName || '').trim()
if (!name) return true
if (name === username) return true
if (name.endsWith('@chatroom')) return true
if (name.startsWith('Group_') || name.startsWith('Unknown_')) return true
return false
}
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
const cache = readCacheFile(accountRoot)
if (!cache?.contacts?.length) return contacts
const avatarByUsername = new Map(
cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
)
const nameByUsername = new Map(
cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
return contacts.map((contact) => ({
...contact,
avatar:
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
? contact.avatar
: avatarByUsername.get(contact.m_nsUsrName),
m_nsNickName:
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
? contact.m_nsNickName
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
}))
}
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
cache.self = self
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
const avatarByUsername = new Map(
(cache.contacts || [])
.filter((contact) => contact.m_nsUsrName && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
)
const nameByUsername = new Map(
(cache.contacts || [])
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
cache.contacts = contacts.map((contact) => ({
...contact,
avatar:
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
? contact.avatar
: avatarByUsername.get(contact.m_nsUsrName),
m_nsNickName:
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
? contact.m_nsNickName
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
}))
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
const cache = loadOrCreate(accountRoot)
if (!cache || !cache.contacts?.length) return
let changed = false
cache.contacts = cache.contacts.map((contact) => {
const avatar = avatars[contact.m_nsUsrName]
if (!avatar || contact.avatar === avatar) return contact
changed = true
return { ...contact, avatar }
})
if (!changed) return
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function getCachedMessages(
accountRoot: string,
userMd5: string,
startTime?: number,
endTime?: number
): Message[] {
const cache = readCacheFile(accountRoot)
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)]
return bucket?.items || []
}
export function saveCachedMessages(
accountRoot: string,
userMd5: string,
startTime: number | undefined,
endTime: number | undefined,
messages: Message[]
): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
const nextMessages = cache.messages || {}
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = {
updatedAt: Date.now(),
startTime,
endTime,
items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
}
pruneMessageBuckets(nextMessages)
cache.messages = nextMessages
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
+474
View File
@@ -0,0 +1,474 @@
import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from '../message-parser'
import type {
DatabaseKeyValidationCode,
DatabaseKeyValidationResult
} from '../../shared/database-key'
export function getCurrentKey(): string {
if (!dbRef) return ''
try {
return dbRef.getWcdb4Client().getKey()
} catch {
return ''
}
}
export function getCurrentAccountRoot(): string {
if (!dbRef) return ''
try {
return dbRef.getWcdb4Client().getAccountRoot()
} catch {
return ''
}
}
export interface FormattedContact {
m_nsUsrName: string
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface FormattedMessage {
id: string
from: string
type: string
datetime: string
content: string
isSender: boolean
img?: string
name?: string
senderId?: string
contentData?: ReturnType<typeof parseMessageContent>
voiceDataUrl?: string
voiceDuration?: number
localId?: number
createTime?: number
sessionId?: string
}
export interface GroupSnapshot {
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
}
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
let dbRef: WechatDb | null = null
export function setChatDb(db: WechatDb | null): void {
dbRef?.close()
dbRef = db
}
export function getChatDb(): WechatDb | null {
return dbRef
}
export function isReady(): boolean {
return dbRef !== null
}
export function listContacts(filter?: string): FormattedContact[] {
if (!dbRef) return []
const contacts: FormattedContact[] = []
const groupContacts = dbRef.getAllGroupContacts()
const userList = dbRef.getUserList(filter)
const existingMd5s = new Set<string>()
for (const user of userList) {
const md5 = dbRef.md5(user.m_nsUsrName)
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5,
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
})
}
const chatTables = dbRef.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (existingMd5s.has(md5)) continue
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5,
type: 'user'
})
}
}
return contacts
}
export function getContactAvatars(usernames: string[]): Record<string, string> {
if (!dbRef) return {}
const normalized = Array.from(
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
)
if (normalized.length === 0) return {}
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
}
export function listMessages(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
): FormattedMessage[] {
if (!dbRef) return []
const startedAt = Date.now()
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
console.log(
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
)
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
console.log(
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
const formatted = rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
let content = msg.msgContent
let img = ''
let name = ''
let senderId = typeof msg.sender === 'string' ? msg.sender : ''
if (isMine) {
name = typeof msg.senderNickname === 'string' ? msg.senderNickname : ''
} else {
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
}
if (isGroupChat && content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialSenderId = content.substring(0, colonIndex).trim()
if (/^[a-zA-Z0-9_@.-]{3,64}$/.test(potentialSenderId)) {
senderId = senderId || potentialSenderId
name = name || potentialSenderId
content = content.substring(colonIndex + 1).replace(/^\s+/, '')
}
}
}
if (!isMine && !name && senderId) name = senderId
let contentData: ReturnType<typeof parseMessageContent> | undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&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,
senderId,
sessionId: username,
localId,
createTime,
contentData
}
})
console.log(
`[ChatService] listMessages end md5=${userMd5} formatted=${formatted.length} cost=${Date.now() - startedAt}ms`
)
return formatted
}
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return { roomId, memberCount: members.length, members }
}
export function searchMessages(keyword: string): string | null {
if (!dbRef) return null
return dbRef.searchAllMessages(keyword)
}
export function listRecentChat(limit = 50): FormattedContact[] {
const contacts = listContacts()
return contacts.slice(0, limit)
}
export function resolveMd5(query: string): FormattedContact | null {
const trimmed = query.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
const contacts = listContacts()
const exact = contacts.find(
(c) =>
c.md5 === trimmed ||
c.m_nsUsrName.toLowerCase() === lower ||
c.m_nsNickName.toLowerCase() === lower
)
if (exact) return exact
const partial = contacts.find(
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
)
return partial || null
}
export interface SelfAccountInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
export function getSelfAccountInfo(): SelfAccountInfo | null {
if (!dbRef) return null
const wcdb = dbRef.getWcdb4Client()
const accountRoot = wcdb.getAccountRoot()
const usernameCandidates = wcdb.getMyUsernameCandidates()
const primaryUsername = usernameCandidates[0] ?? ''
const wxid =
primaryUsername && primaryUsername.toLowerCase().startsWith('wxid_')
? primaryUsername
: wcdb.getUsernameByMd5(wcdb.md5(accountRoot.split('/').pop() || '')) || primaryUsername
let nickname = ''
let avatar: string | undefined
try {
avatar = wcdb.getMyAvatarUrl()
} catch {
avatar = undefined
}
if (usernameCandidates.length) {
const contacts = listContacts()
const self = contacts.find(
(c) =>
usernameCandidates.includes(c.m_nsUsrName) ||
(c.type === 'user' && usernameCandidates.some((u) => c.m_nsUsrName.includes(u)))
)
if (self) {
nickname = self.m_nsNickName
avatar = avatar || self.avatar
}
}
return {
wxid: wxid || primaryUsername || '',
nickname: nickname || wxid || '我',
avatar,
accountRoot
}
}
export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult {
const probeKey = key.replace(/^0x/i, '').trim()
if (!/^[0-9a-f]{64}$/i.test(probeKey)) {
return {
success: false,
code: 'INVALID_FORMAT',
error: '密钥格式不正确'
}
}
let probe: WechatDb | null = null
let ownsProbe = false
try {
const current = dbRef
const canReuseCurrent =
current &&
getCurrentKey().replace(/^0x/i, '').trim() === probeKey &&
(!accountRoot || getCurrentAccountRoot() === accountRoot)
const validationDb = canReuseCurrent
? current
: accountRoot
? new WechatDb(probeKey, accountRoot)
: new WechatDb(probeKey)
probe = validationDb
ownsProbe = validationDb !== current
const client = validationDb.getWcdb4Client()
const contacts = client.getSessions()
const messages = client.getChatTables()
if (contacts[0]) client.getMessages(contacts[0].username, undefined, undefined, { limit: 1 })
return {
success: true,
accountRoot: client.getAccountRoot(),
wxid: (client.getMyUsernameCandidates?.() ?? [])[0] || '',
contacts: { available: true, count: contacts.length },
messages: { available: true, count: messages.length }
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
const code = mapConnectionError(detail)
return {
success: false,
code,
error: DATABASE_KEY_ERROR_MESSAGES[code]
}
} finally {
try {
// macOS native shutdown is process-wide. Do not tear down the active reader
// when validating a replacement key; the new runtime connection takes over on save.
if (ownsProbe && !(process.platform === 'darwin' && dbRef)) probe?.close()
} catch {
// Validation probes are best-effort closed without exposing native details.
}
}
}
const DATABASE_KEY_ERROR_MESSAGES: Record<DatabaseKeyValidationCode, string> = {
INVALID_FORMAT: '密钥格式不正确',
DATABASE_OPEN_FAILED: '无法打开数据库',
ACCOUNT_MISMATCH: '密钥与当前账号不匹配',
ROOT_UNAVAILABLE: '当前数据库目录不可用',
DATABASE_FILE_MISSING: '数据库文件缺失',
UNKNOWN_VALIDATION_ERROR: '未知验证错误'
}
function mapConnectionError(detail: string): DatabaseKeyValidationCode {
const normalized = detail.toLowerCase()
if (normalized.includes('-1005') || normalized.includes('不匹配')) return 'ACCOUNT_MISMATCH'
if (normalized.includes('session.db') || normalized.includes('数据库文件')) {
return 'DATABASE_FILE_MISSING'
}
if (
normalized.includes('数据目录') ||
normalized.includes('账号目录') ||
normalized.includes('db_storage')
) {
return 'ROOT_UNAVAILABLE'
}
if (normalized.includes('wcdb_open_account') || normalized.includes('open')) {
return 'DATABASE_OPEN_FAILED'
}
return 'UNKNOWN_VALIDATION_ERROR'
}
export function reopenWithRoot(accountRoot: string): boolean {
if (!dbRef) return false
const key = getCurrentKey()
if (!key) return false
try {
const next = new WechatDb(key, accountRoot)
setChatDb(next)
return true
} catch (error) {
console.error('[ChatService] reopen with root failed:', error)
return false
}
}
@@ -0,0 +1,139 @@
import { app } from 'electron'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import type {
ImageDecryptionStatus,
ImageDecryptionTestResult,
ImageKeyConfigResult,
ImageResourceCheck,
TestImageDecryptionRequest
} from '../../shared/image-decryption'
import { ImageDecryptService } from '../image-decrypt-service'
import * as chat from './chat-service'
import { validateImageKeyRequest } from './image-key-config-service'
import { isWechatRunning } from './wechat-process-status'
export async function inspectImageDecryptionStatus(
config: ImageKeyConfigResult
): Promise<ImageDecryptionStatus> {
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
const imageDirectoryFound = hasImageDirectory(accountRoot)
const stickerCacheFound =
fs.existsSync(path.join(accountRoot, 'cache')) ||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
const dbConnected = chat.isReady()
return {
configured: config.configured,
saved: config.saved,
encryptionAvailable: config.encryptionAvailable,
source: config.source,
accountId: config.accountId,
resourceRoot: accountRoot,
updatedAt: config.updatedAt,
platform: process.platform,
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
wechatRunning: await isWechatRunning(),
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
resources: {
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
thumbnail: pending(imageDirectoryFound),
original: pending(imageDirectoryFound),
sticker: stickerCacheFound
? check(true, '本地缓存可用')
: { state: 'unknown', detail: '独立按需解析' },
video: { state: 'unavailable', detail: '当前版本未提供视频媒体解析' }
}
}
}
export function testImageDecryption(
request: TestImageDecryptionRequest
): ImageDecryptionTestResult {
const normalized = validateImageKeyRequest(request)
if (!normalized.success) return failure('NOT_CONFIGURED', normalized.error)
if (!chat.isReady() || !request.userMd5) {
return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录')
}
try {
const messages = chat.listMessages(request.userMd5, undefined, undefined, { limit: 300 })
const imageMessage = [...messages]
.reverse()
.find((message) => message.contentData?.type === 'image')
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
}
const service = new ImageDecryptService(
normalized.xorKey,
normalized.aesKey,
chat.getChatDb()?.getWcdb4Client()
)
const image = imageMessage.contentData
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
if (!filePath)
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
const data = service.decryptImageToBase64(filePath)
if (!data) {
return {
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
fileFound: true
}
}
const readable = data.startsWith('data:image/')
return {
success: readable,
code: readable ? undefined : 'DECRYPT_FAILED',
error: readable ? undefined : '图片解密结果不可读取',
fileFound: true,
decrypted: true,
readable,
isThumbnail: service.isThumbnailFile(filePath)
}
} catch {
return failure('UNKNOWN', '图片解析测试未通过')
}
}
function hasImageDirectory(accountRoot: string): boolean {
if (!accountRoot) return false
return [
path.join(accountRoot, 'msg', 'attach'),
path.join(accountRoot, 'FileStorage', 'Image'),
path.join(accountRoot, 'FileStorage', 'Image2'),
path.join(accountRoot, 'FileStorage', 'MsgImg')
].some((candidate) => fs.existsSync(candidate))
}
function canUseCacheRoot(): boolean {
try {
const cacheRoot = path.join(app.getPath('userData'), 'cache')
fs.ensureDirSync(cacheRoot)
return fs.statSync(cacheRoot).isDirectory()
} catch {
return false
}
}
function check(available: boolean, detail: string): ImageResourceCheck {
return { state: available ? 'available' : 'unavailable', detail }
}
function pending(directoryFound: boolean): ImageResourceCheck {
return directoryFound
? { state: 'unknown', detail: '通过图片解析测试确认' }
: { state: 'unavailable', detail: '图片目录不可用' }
}
function failure(
code: NonNullable<ImageDecryptionTestResult['code']>,
error: string
): ImageDecryptionTestResult {
return { success: false, code, error, fileFound: false, decrypted: false, readable: false }
}
@@ -0,0 +1,207 @@
import path from 'path'
import type { ImageKeyConfigResult, SaveImageKeyRequest } from '../../shared/image-decryption'
import { ImageKeyStore } from '../image-key-store'
import * as chat from './chat-service'
import { loadSettings, saveSettings, type AppSettings } from './settings-store'
export class ImageKeyConfigService {
constructor(private readonly store = new ImageKeyStore()) {}
getConfig(): ImageKeyConfigResult {
const settings = loadSettings()
const context = this.getContext(settings)
const stored = this.store.get(context.accountId)
if (!stored.success) {
return {
success: false,
configured: false,
saved: false,
encryptionAvailable: stored.encryptionAvailable,
source: 'none',
accountId: context.accountId,
resourceRoot: context.resourceRoot,
error: stored.error
}
}
if (stored.entry) {
return {
success: true,
configured: true,
saved: true,
encryptionAvailable: stored.encryptionAvailable,
source: 'secure-storage',
accountId: context.accountId,
resourceRoot: context.resourceRoot,
xorKey: stored.entry.xorKey,
aesKey: stored.entry.aesKey,
updatedAt: stored.entry.updatedAt
}
}
if (settings.imageAesKey.trim()) {
const legacy = this.buildResult({
source: 'legacy-settings',
xorKey: settings.imageXorKey || '0x40',
aesKey: settings.imageAesKey,
context,
encryptionAvailable: stored.encryptionAvailable
})
if (!stored.encryptionAvailable) return legacy
const migrated = this.save({
resourceRoot: context.resourceRoot,
xorKey: legacy.xorKey || '0x40',
aesKey: legacy.aesKey || ''
})
return migrated.success ? this.getConfig() : legacy
}
const envAesKey = String(import.meta.env.VITE_IMAGE_AES_KEY || '').trim()
if (!settings.imageKeyFallbackDisabled && envAesKey) {
return this.buildResult({
source: 'environment',
xorKey: String(import.meta.env.VITE_IMAGE_XOR_KEY || '0x40'),
aesKey: envAesKey,
context,
encryptionAvailable: stored.encryptionAvailable
})
}
return {
success: true,
configured: false,
saved: false,
encryptionAvailable: stored.encryptionAvailable,
source: 'none',
accountId: context.accountId,
resourceRoot: context.resourceRoot
}
}
save(request: SaveImageKeyRequest): ImageKeyConfigResult {
const normalized = validateImageKeyRequest(request)
if (!normalized.success) return { ...this.getEmptyConfig(), error: normalized.error }
const settings = loadSettings()
const context = this.getContext(settings)
if (!chat.getSelfAccountInfo()?.wxid) {
return { ...this.getEmptyConfig(), error: '当前微信账号尚未识别' }
}
const result = this.store.save(context.accountId, {
xorKey: normalized.xorKey,
aesKey: normalized.aesKey
})
if (!result.success || !result.entry) {
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
}
saveSettings({
...settings,
imageKeyRoot: normalized.resourceRoot,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false
})
return {
success: true,
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
accountId: context.accountId,
resourceRoot: normalized.resourceRoot,
xorKey: result.entry.xorKey,
aesKey: result.entry.aesKey,
updatedAt: result.entry.updatedAt
}
}
clear(): { success: boolean; error?: string } {
const settings = loadSettings()
const context = this.getContext(settings)
const cleared = this.store.clear(context.accountId)
if (!cleared.success) return cleared
saveSettings({
...settings,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: true
})
return { success: true }
}
getLegacySettingsView(): AppSettings {
const settings = loadSettings()
const config = this.getConfig()
return {
...settings,
imageXorKey: config.xorKey || '',
imageAesKey: config.aesKey || ''
}
}
private getContext(settings: AppSettings): { accountId: string; resourceRoot: string } {
const self = chat.getSelfAccountInfo()
const accountRoot = self?.accountRoot || chat.getCurrentAccountRoot() || settings.dbRoot
return {
accountId: self?.wxid || path.basename(accountRoot || '') || 'unbound',
resourceRoot: settings.imageKeyRoot || accountRoot || settings.dbRoot
}
}
private buildResult(input: {
source: 'legacy-settings' | 'environment'
xorKey: string
aesKey: string
context: { accountId: string; resourceRoot: string }
encryptionAvailable: boolean
}): ImageKeyConfigResult {
return {
success: true,
configured: true,
saved: false,
encryptionAvailable: input.encryptionAvailable,
source: input.source,
accountId: input.context.accountId,
resourceRoot: input.context.resourceRoot,
xorKey: normalizeImageXorKey(input.xorKey),
aesKey: input.aesKey.trim()
}
}
private getEmptyConfig(): ImageKeyConfigResult {
const settings = loadSettings()
const context = this.getContext(settings)
const stored = this.store.get(context.accountId)
return {
success: false,
configured: false,
saved: false,
encryptionAvailable: stored.encryptionAvailable,
source: 'none',
accountId: context.accountId,
resourceRoot: context.resourceRoot
}
}
}
export function normalizeImageXorKey(value: unknown): string {
const raw = String(value ?? '').trim()
if (!raw) return '0x40'
const parsed = raw.toLowerCase().startsWith('0x')
? Number.parseInt(raw.slice(2), 16)
: Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 255) return raw
return `0x${parsed.toString(16).toUpperCase().padStart(2, '0')}`
}
export function validateImageKeyRequest(
request: SaveImageKeyRequest
):
| { success: true; resourceRoot: string; xorKey: string; aesKey: string }
| { success: false; error: string } {
const resourceRoot = request.resourceRoot.trim()
const xorKey = normalizeImageXorKey(request.xorKey)
const aesKey = request.aesKey.trim()
if (!resourceRoot) return { success: false, error: '图片资源目录不能为空' }
if (!/^0x[0-9A-F]{2}$/.test(xorKey)) return { success: false, error: 'XOR Key 格式不正确' }
if (aesKey.length !== 16) return { success: false, error: 'AES Key 必须为 16 个字符' }
return { success: true, resourceRoot, xorKey, aesKey }
}
+153
View File
@@ -0,0 +1,153 @@
import http from 'http'
import { apiServer } from '../http-server'
import {
LOCAL_API_ENDPOINTS,
type LocalApiEndpointId,
type LocalApiTestRequest,
type LocalApiTestResponse
} from '../../shared/local-api-test'
const REQUEST_TIMEOUT_MS = 10_000
const MAX_BODY_SIZE = 512 * 1024
function isEndpointId(value: unknown): value is LocalApiEndpointId {
return typeof value === 'string' && value in LOCAL_API_ENDPOINTS
}
function requestHost(host: string): string {
if (host === '0.0.0.0') return '127.0.0.1'
if (host === '::') return '::1'
return host
}
function invalidResponse(message: string): LocalApiTestResponse {
return {
ok: false,
method: 'GET',
path: '',
url: '',
durationMs: 0,
responseSize: 0,
errorCode: 'INVALID_REQUEST',
error: message
}
}
function parseBody(bodyText: string, contentType?: string): { json?: unknown; bodyText?: string } {
if (contentType?.includes('application/json')) {
try {
return { json: JSON.parse(bodyText) }
} catch {
// Keep malformed responses readable to the tester.
}
}
return { bodyText }
}
export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTestResponse> {
if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效')
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
if (!isEndpointId(endpointId)) return invalidResponse('不允许访问该 API 端点')
if (!query || typeof query !== 'object' || Array.isArray(query))
return invalidResponse('查询参数格式无效')
if (typeof body !== 'string' || Buffer.byteLength(body) > MAX_BODY_SIZE)
return invalidResponse('请求体格式无效')
const endpoint = LOCAL_API_ENDPOINTS[endpointId]
const entries = Object.entries(query)
if (
entries.some(
([key, value]) => !endpoint.queryKeys.includes(key as never) || typeof value !== 'string'
)
) {
return invalidResponse('查询参数不属于当前端点')
}
const service = apiServer.getState()
const targetHost = requestHost(service.host)
const targetPort = service.port
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
const url = new URL(endpoint.path, `http://${hostPart}:${targetPort}`)
entries.forEach(([key, value]) => {
if (value.trim()) url.searchParams.set(key, value.trim())
})
if (!service.running) {
return {
ok: false,
method: endpoint.method,
path: endpoint.path,
url: url.toString(),
durationMs: 0,
responseSize: 0,
errorCode: 'API_NOT_RUNNING',
error: '请先启动本地 API 服务'
}
}
return new Promise((resolve) => {
const startedAt = Date.now()
let settled = false
const finish = (result: LocalApiTestResponse): void => {
if (settled) return
settled = true
resolve(result)
}
const request = http.request(
url,
{
method: endpoint.method,
headers: endpoint.method === 'POST' ? { 'Content-Type': 'application/json' } : undefined
},
(response) => {
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', () => {
const bodyText = Buffer.concat(chunks).toString('utf-8')
const contentType = Array.isArray(response.headers['content-type'])
? response.headers['content-type'][0]
: response.headers['content-type']
finish({
ok: true,
method: endpoint.method,
path: endpoint.path,
url: url.toString(),
status: response.statusCode || 0,
statusText: response.statusMessage || '',
durationMs: Date.now() - startedAt,
responseSize: Buffer.byteLength(bodyText),
contentType,
...parseBody(bodyText, contentType)
})
})
}
)
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
request.destroy(new Error('请求超时'))
finish({
ok: false,
method: endpoint.method,
path: endpoint.path,
url: url.toString(),
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: 'TIMEOUT',
error: '请求超时(10 秒)'
})
})
request.on('error', (error: NodeJS.ErrnoException) => {
finish({
ok: false,
method: endpoint.method,
path: endpoint.path,
url: url.toString(),
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: error.code === 'ECONNREFUSED' ? 'CONNECTION_REFUSED' : 'UNKNOWN',
error: error.message
})
})
if (endpoint.method === 'POST') request.write(body)
request.end()
})
}
+183
View File
@@ -0,0 +1,183 @@
import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
export interface AppSettings {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
imageKeyRoot: string
imageXorKey: string
imageAesKey: string
imageKeyFallbackDisabled: boolean
}
function getDefaultDbRoot(): string {
const home = os.homedir()
const candidates = getDefaultDbRootCandidates(home)
return candidates.find((candidate) => isUsableDbRoot(candidate)) || candidates[0]
}
function getDefaultDbRootCandidates(home: string): string[] {
if (process.platform !== 'win32') {
return [
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
]
}
const candidates = [
...getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'WeChat Files'),
path.join(home, 'Documents', 'xwechat_files'),
path.join(home, 'WeChat Files'),
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
for (const drive of getWindowsDrives()) {
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
for (const child of listDirectories(`${drive}:\\`)) {
candidates.push(path.join(child, 'xwechat_files'))
candidates.push(path.join(child, 'WeChat Files'))
}
}
return unique(candidates)
}
function getWeflowDbPathCandidates(home: string): string[] {
const configPaths = [
path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
]
const candidates: string[] = []
for (const configPath of configPaths) {
try {
const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
candidates.push(config.dbPath.trim())
}
} catch {
// WeFlow is optional; ignore missing or unreadable config.
}
}
return candidates
}
function getWindowsDrives(): string[] {
const drives: string[] = []
for (let code = 67; code <= 90; code += 1) {
const drive = String.fromCharCode(code)
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
}
return drives
}
function listDirectories(root: string): string[] {
try {
return fs
.readdirSync(root)
.map((name) => path.join(root, name))
.filter((candidate) => {
try {
return fs.statSync(candidate).isDirectory()
} catch {
return false
}
})
} catch {
return []
}
}
function unique(values: string[]): string[] {
return Array.from(new Set(values))
}
function isUsableDbRoot(candidate?: string): boolean {
if (!candidate || !fs.existsSync(candidate)) return false
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
try {
return fs
.readdirSync(candidate)
.some((name) => fs.existsSync(path.join(candidate, name, 'db_storage')))
} catch {
return false
}
}
const defaultDbRoot = getDefaultDbRoot()
const DEFAULT_SETTINGS: AppSettings = {
dbRoot: defaultDbRoot,
apiEnabled: true,
apiHost: '127.0.0.1',
apiPort: 6131,
imageKeyRoot: defaultDbRoot,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false
}
const SETTINGS_FILE = path.join(
process.env['WE_SETTINGS_DIR'] || app.getPath('userData'),
'settings.json'
)
let cache: AppSettings | null = null
function ensureDir(): void {
fs.ensureDirSync(path.dirname(SETTINGS_FILE))
}
export function loadSettings(): AppSettings {
if (cache) return cache
try {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
cache = { ...DEFAULT_SETTINGS, ...raw }
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
cache.dbRoot = getDefaultDbRoot()
}
if (!cache.imageKeyRoot) {
cache.imageKeyRoot = cache.dbRoot
}
return cache
}
} catch (error) {
console.warn('[Settings] failed to load, fallback to defaults:', error)
}
cache = { ...DEFAULT_SETTINGS }
return cache
}
export function saveSettings(next: AppSettings): AppSettings {
cache = { ...next }
try {
ensureDir()
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
} catch (error) {
console.error('[Settings] failed to save:', error)
}
return cache
}
export function updateSettings(patch: Partial<AppSettings>): AppSettings {
return saveSettings({ ...loadSettings(), ...patch })
}
export function resetSettings(): AppSettings {
cache = null
try {
if (fs.existsSync(SETTINGS_FILE)) fs.unlinkSync(SETTINGS_FILE)
} catch {
// best effort
}
return loadSettings()
}
export function getSettingsPath(): string {
return SETTINGS_FILE
}
@@ -0,0 +1,91 @@
import { app, shell } from 'electron'
import { existsSync, promises as fs } from 'fs'
import { dirname, join } from 'path'
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
const GITHUB_URL =
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/wechatexplorer-reader'
export interface SkillResourceStatus {
available: boolean
version?: string
filePath?: string
directoryPath?: string
source: 'development' | 'bundled'
githubUrl: string
error?: string
}
function getSkillCandidates(): { path: string; source: 'development' | 'bundled' }[] {
const developmentPath = join(app.getAppPath(), 'docs', SKILL_RELATIVE_PATH)
const bundledPaths = [
join(process.resourcesPath, SKILL_RELATIVE_PATH),
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
]
return app.isPackaged
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
: [
{ path: developmentPath, source: 'development' as const },
...bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
]
}
function getStatus(): SkillResourceStatus {
const candidates = getSkillCandidates()
const resolved = candidates.find((candidate) => existsSync(candidate.path))
const filePath = resolved?.path || candidates[0].path
const source = resolved?.source || candidates[0].source
const directoryPath = dirname(filePath)
if (!resolved) {
return {
available: false,
source,
githubUrl: GITHUB_URL,
error: `未找到 WechatExplorer Reader Skill 文件(已检查:${candidates.map((item) => item.path).join('')}`
}
}
return {
available: true,
version: 'v1.0',
filePath,
directoryPath,
source,
githubUrl: GITHUB_URL
}
}
export const skillResourceService = {
getStatus,
async read(): Promise<{ success: boolean; content?: string; error?: string }> {
const status = this.getStatus()
if (!status.available || !status.filePath) return { success: false, error: status.error }
try {
return { success: true, content: await fs.readFile(status.filePath, 'utf-8') }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
},
async reveal(): Promise<{ success: boolean; error?: string }> {
const status = this.getStatus()
if (!status.available || !status.directoryPath) return { success: false, error: status.error }
try {
const error = await shell.openPath(status.directoryPath)
if (error) return { success: false, error }
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
},
async openGithub(): Promise<{ success: boolean; error?: string }> {
try {
await shell.openExternal(GITHUB_URL)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
@@ -0,0 +1,41 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const WINDOWS_WECHAT_IMAGES = ['Weixin.exe', 'WeChat.exe']
export async function isWindowsWechatRunning(): Promise<boolean> {
if (process.platform !== 'win32') return false
for (const imageName of WINDOWS_WECHAT_IMAGES) {
try {
const { stdout } = await execFileAsync(
'tasklist',
['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'],
{ windowsHide: true }
)
if (stdout.toLowerCase().includes(`"${imageName.toLowerCase()}"`)) return true
} catch {
// Try the other supported WeChat executable name.
}
}
return false
}
export async function isWechatRunning(): Promise<boolean> {
if (process.platform === 'win32') return isWindowsWechatRunning()
if (process.platform !== 'darwin') return false
for (const args of [
['-x', 'WeChat'],
['-f', 'WeChat.app/Contents/MacOS/WeChat']
]) {
try {
const { stdout } = await execFileAsync('/usr/bin/pgrep', args)
if (stdout.trim()) return true
} catch {
// Try the next macOS process lookup strategy.
}
}
return false
}
+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
+104 -268
View File
@@ -1,14 +1,9 @@
import DatabaseConstructor from 'better-sqlite3-multiple-ciphers'
import fs from 'fs-extra'
import path from 'path'
import crypto from 'crypto'
import os from 'os'
type Database = import('better-sqlite3-multiple-ciphers').Database
import { Wcdb4Client, Wcdb4MessageQueryOptions } from './wcdb4-client'
export interface UserContact {
m_nsUsrName: string
nickname: string
avatar?: string
}
export interface WechatMessage {
@@ -26,6 +21,7 @@ export interface Contact {
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface GroupMemberInfo {
@@ -35,308 +31,148 @@ export interface GroupMemberInfo {
}
export class WechatDb {
private static WECHAT_DIR = path.join(
os.homedir(),
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
)
private wcdb4Client: Wcdb4Client
private chatMd5ToUsername = new Map<string, string>()
private rawKey: string
private correctUserId: string | null = null
private chatDb: { name: string; db_number: string }[] | null = null
private groupMemberCache = new Map<string, GroupMemberInfo | null>()
constructor(rawKey: string) {
this.rawKey = rawKey
console.log(`Initializing WechatDb with key: ${rawKey}`)
if (!fs.existsSync(WechatDb.WECHAT_DIR)) {
throw new Error(`WeChat directory not found at ${WechatDb.WECHAT_DIR}`)
}
if (this.getUser()) {
console.log('User found, getting chat DB number')
this.chatDb = this.getChatDbNumber()
} else {
throw new Error('No valid user found or invalid key')
}
}
private connectDb(dbPath: string): Database | null {
const targetPath = path.resolve(dbPath)
if (!fs.existsSync(targetPath)) {
console.error(`❌ 错误:数据库文件不存在于路径:${targetPath}`)
return null
}
try {
const db = new DatabaseConstructor(targetPath, {
// verbose: console.log
})
// 配置加密方案 (Cipher Scheme)
db.pragma("cipher='sqlcipher'")
db.pragma('cipher_page_size = 1024')
db.pragma('legacy=3')
db.pragma('kdf_iter = 64000')
db.pragma('cipher_hmac_algorithm = HMAC_SHA1')
db.pragma('cipher_kdf_algorithm = PBKDF2_HMAC_SHA1')
const processedKey = this.processRawKey(this.rawKey)
// console.log(`🔑 应用密钥 (前6位): ${processedKey.substring(0, 6)}...`)
db.pragma(`key = "x'${processedKey}'"`)
// 验证连接
db.prepare('SELECT count(*) as count FROM sqlite_master').get()
return db
} catch (error) {
console.error(`❌ 连接失败:`, error)
const errString = String(error)
if (errString.includes('file is not a database')) {
console.error(
"💡 诊断建议:\n1. 请确认是否已安装 'better-sqlite3-multiple-ciphers'。\n2. 检查密钥是否正确(WeChat密钥与设备强绑定)。\n3. 尝试将页大小改为 4096 (db.pragma('cipher_page_size = 4096'))。"
)
} else if (errString.includes('HMAC')) {
console.error(
'💡 诊断建议:密钥可能正确,但HMAC算法或页大小不匹配。尝试调整 cipher_hmac_algorithm。'
)
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
// WCDB native init must run on the Electron main process; worker threads
// get -1006 from wcdb_init. Initialize the client synchronously here
// (and keep create() async for callers that already await it).
return new Promise((resolve, reject) => {
try {
const client = new Wcdb4Client(rawKey, accountRoot)
client.open()
resolve(new WechatDb(rawKey, accountRoot, client))
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)))
}
return null
}
})
}
private processRawKey(key: string): string {
// 移除可能的 '0x' 前缀并修剪空格
return key.replace(/^0x/i, '').trim()
}
private getUser(): boolean {
const keyValuePath = path.join(WechatDb.WECHAT_DIR, 'KeyValue')
if (!fs.existsSync(keyValuePath)) return false
const contents = fs.readdirSync(keyValuePath)
const potentialUsers = contents.filter((name) => !name.startsWith('.'))
for (const user of potentialUsers) {
const dbPath = path.join(keyValuePath, user, 'KeyValue.db')
if (fs.existsSync(dbPath)) {
const db = this.connectDb(dbPath)
if (db) {
this.correctUserId = user
console.log(`Valid user found: ${user}`)
db.close()
return true
}
constructor(
rawKey: string,
accountRoot?: string,
clientOverride?: Wcdb4Client,
initialChatTables?: { name: string; db_number: string }[]
) {
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
const client =
clientOverride || new Wcdb4Client(rawKey, accountRoot)
if (!clientOverride) client.open()
this.wcdb4Client = client
for (const table of initialChatTables || client.getChatTables()) {
if (table.name.startsWith('Chat_')) {
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
}
}
return false
}
private getChatDbNumber(): { name: string; db_number: string }[] {
const chatDb: { name: string; db_number: string }[] = []
if (!this.correctUserId) return []
for (let i = 0; i < 10; i++) {
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', `msg_${i}.db`)
if (!fs.existsSync(dbPath)) continue
const db = this.connectDb(dbPath)
if (db) {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
.all() as { name: string }[]
for (const table of tables) {
chatDb.push({ name: table.name, db_number: `msg_${i}.db` })
}
db.close()
}
}
return chatDb
}
public getUserList(nicknameFilter?: string): UserContact[] {
if (!this.correctUserId) return []
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Contact/wccontact_new2.db')
const db = this.connectDb(dbPath)
if (!db) return []
let query = "SELECT m_nsUsrName, nickname FROM WCContact WHERE m_nsUsrName NOT LIKE 'gh_%'"
if (nicknameFilter) {
query += ` AND nickname LIKE '%${nicknameFilter}%'`
}
const results = db.prepare(query).all() as unknown as UserContact[]
db.close()
return results
const keyword = (nicknameFilter || '').trim().toLowerCase()
return this.wcdb4Client
.getSessions()
.map((session) => ({
m_nsUsrName: session.username,
nickname: session.nickname || session.username,
avatar: session.avatar
}))
.filter((contact) => {
if (!keyword) return true
return (
contact.m_nsUsrName.toLowerCase().includes(keyword) ||
contact.nickname.toLowerCase().includes(keyword)
)
})
}
public getAllGroupContacts(): Record<string, string> {
if (!this.correctUserId) return {}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
if (!db) return {}
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupContact').all() as {
m_nsUsrName: string
nickname: string
}[]
db.close()
const groupContacts: Record<string, string> = {}
for (const row of results) {
if (row.m_nsUsrName && row.nickname) {
const md5 = this.md5(row.m_nsUsrName)
groupContacts[md5] = row.nickname
for (const session of this.wcdb4Client.getSessions()) {
if (session.username.endsWith('@chatroom')) {
groupContacts[this.md5(session.username)] = session.nickname || session.username
}
}
return groupContacts
}
public getAllGroupMembers(): Record<string, string> {
if (!this.correctUserId) return {}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
if (!db) return {}
// 查询 GroupMember 表
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupMember').all() as {
m_nsUsrName: string
nickname: string
}[]
db.close()
const groupMembers: Record<string, string> = {}
for (const row of results) {
if (row.m_nsUsrName && row.nickname) {
// 将 wxid (m_nsUsrName) 映射到昵称
groupMembers[row.m_nsUsrName] = row.nickname
const members: Record<string, string> = {}
for (const session of this.wcdb4Client.getSessions()) {
if (!session.username.endsWith('@chatroom')) continue
for (const member of this.wcdb4Client.getGroupMembers(session.username)) {
if (member.m_nsUsrName) {
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
}
}
}
return groupMembers
return members
}
public getGroupMember(wxid: string): GroupMemberInfo | null {
if (!this.correctUserId) return null
public getGroupMembersForChat(userMd5: string): Record<string, string> {
const username = this.chatMd5ToUsername.get(userMd5)
if (!username || !username.endsWith('@chatroom')) return {}
// 检查缓存
if (this.groupMemberCache.has(wxid)) {
return this.groupMemberCache.get(wxid) || null
const members: Record<string, string> = {}
for (const member of this.wcdb4Client.getGroupMembers(username)) {
if (member.m_nsUsrName) {
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
}
}
return members
}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
if (!db) {
this.groupMemberCache.set(wxid, null)
return null
}
const result = db
.prepare(
'SELECT m_nsUsrName, nickname, m_nsHeadImgUrl FROM GroupMember WHERE m_nsUsrName = ?'
)
.get(wxid) as GroupMemberInfo
db.close()
// 写入缓存
this.groupMemberCache.set(wxid, result || null)
return result || null
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
if (!chatroomId) return null
return (
this.wcdb4Client
.getGroupMembers(chatroomId)
.find((member) => member.m_nsUsrName === wxid) || null
)
}
public getAllChatTables(): { name: string; db_number: string }[] {
return this.chatDb || []
return this.wcdb4Client.getChatTables()
}
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
if (!this.chatDb || !this.correctUserId) return []
public getMyAvatarUrl(): string | undefined {
return this.wcdb4Client.getMyAvatarUrl()
}
const tableName = `Chat_${userMd5}`
const tableInfo = this.chatDb.find((t) => t.name === tableName)
public getWcdb4Client(): Wcdb4Client {
return this.wcdb4Client
}
if (!tableInfo) {
console.error(`No table found for ${tableName}`)
return []
}
public close(): void {
this.wcdb4Client.close()
}
const dbPath = path.join(
WechatDb.WECHAT_DIR,
this.correctUserId,
'Message',
tableInfo.db_number
)
const db = this.connectDb(dbPath)
if (!db) return []
try {
let query = `SELECT * FROM ${tableName}`
const conditions: string[] = []
// console.log(startTime, endTime, 'time range')
if (startTime) {
conditions.push(`msgCreateTime >= ${startTime}`)
}
if (endTime) {
conditions.push(`msgCreateTime <= ${endTime}`)
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`
}
// 添加排序以确保存储正确的顺序
query += ` ORDER BY msgCreateTime ASC`
const results = db.prepare(query).all() as unknown as WechatMessage[]
db.close()
return results
} catch (e) {
console.error(`Error querying messages for ${tableName}:`, e)
db.close()
return []
}
public getUserMessages(
userMd5: string,
startTime?: number,
endTime?: number,
options?: Wcdb4MessageQueryOptions
): WechatMessage[] {
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
...message,
...message.raw
}))
}
public searchAllMessages(keyword: string): string | null {
if (!this.correctUserId) return null
for (let i = 0; i < 10; i++) {
const dbName = `msg_${i}.db`
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', dbName)
if (!fs.existsSync(dbPath)) continue
const db = this.connectDb(dbPath)
if (!db) continue
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
.all() as { name: string }[]
for (const table of tables) {
try {
// 首先检查 msgContent 列是否存在以避免错误?
// 或者像 Swift 代码中那样使用 try/catch "抑制错误"
const results = db
.prepare(`SELECT 1 FROM ${table.name} WHERE msgContent LIKE '%${keyword}%' LIMIT 1`)
.get()
if (results) {
console.log(`Found keyword in ${table.name} (${dbName})`)
db.close()
return table.name
}
} catch {
// 忽略没有 msgContent 的表
}
}
db.close()
const lowerKeyword = keyword.trim().toLowerCase()
if (!lowerKeyword) return null
for (const session of this.wcdb4Client.getSessions()) {
const found = this.wcdb4Client
.getMessages(session.username)
.some((message) => message.msgContent.toLowerCase().includes(lowerKeyword))
if (found) return `Chat_${this.md5(session.username)}`
}
return null
}
public md5(str: string): string {
return crypto.createHash('md5').update(str).digest('hex')
return this.wcdb4Client.md5(str)
}
}
+260 -4
View File
@@ -1,19 +1,275 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types'
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test'
import {
DeleteGeneratedReportResult,
ReportHistoryResult,
SaveGeneratedReportRequest,
SaveGeneratedReportResult
} from '../shared/report-history'
import type {
DatabaseKeyEnvironment,
DatabaseKeyStorageResult,
DatabaseKeyValidationResult
} from '../shared/database-key'
import type {
ImageDecryptionStatus,
ImageDecryptionTestResult,
ImageKeyConfigResult,
SaveImageKeyRequest,
TestImageDecryptionRequest
} from '../shared/image-decryption'
import type {
AIChatRequestOptions,
AIConnectionTestResult,
AIProviderConfig,
AIProviderListResult,
AIRuntimeModelConfig,
AIVisionTestRequest,
AIVisionTestResult,
LegacyAIConfig
} from '../shared/ai-provider'
export type ParsedContent =
| { type: 'text'; content: string }
| { type: 'voice'; duration?: number }
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
| { type: 'voip'; duration?: number; status: string; roomType?: number }
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
| {
type: 'sticker'
md5?: string
url?: string
thumbUrl?: string
encryptUrl?: string
aeskey?: string
}
| {
type: 'quote'
title?: string
content?: string
sender?: string
quotedContent?: string
quotedSender?: string
quotedType?: string
}
| { type: 'system'; content: string }
| { type: 'unknown'; raw: string }
declare global {
interface Window {
electron: ElectronAPI
api: {
initDb: (key: string) => Promise<boolean>
initDb: (
key: string
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
getBootstrapCache: () => Promise<{
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
contacts: Contact[]
updatedAt: number
} | null>
getContacts: (filter?: string) => Promise<Contact[]>
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
getCachedMessages: (
userMd5: string,
startTime?: number,
endTime?: number
) => Promise<Message[]>
getMessages: (
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
) => Promise<Message[]>
getGroupSnapshot: (userMd5: string) => Promise<{
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
} | null>
search: (keyword: string) => Promise<string | null>
aiChat: (
messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string }
) => Promise<{ success: boolean; data?: string; error?: string }>
options?: AIChatRequestOptions
) => Promise<{
success: boolean
data?: string
usage?: {
input?: number
output?: number
total?: number
estimated?: boolean
}
error?: string
}>
listAIProviders: () => Promise<AIProviderListResult>
getAIRuntimeConfig: () => Promise<AIRuntimeModelConfig>
saveAIProvider: (provider: AIProviderConfig) => Promise<AIProviderListResult>
deleteAIProvider: (providerId: string) => Promise<AIProviderListResult>
setDefaultAIProvider: (providerId: string) => Promise<AIProviderListResult>
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
testAIVision: (request: AIVisionTestRequest) => Promise<AIVisionTestResult>
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
) => Promise<{ success: boolean; data?: string; error?: string }>
parseMessage: (content: string, messageType: number) => Promise<ParsedContent>
getImage: (
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean }
) => Promise<{
success: boolean
data?: string
error?: string
isThumb?: boolean
filePath?: string
}>
getSticker: (
cdnUrl?: string,
md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
listGeneratedReports: () => Promise<ReportHistoryResult>
saveGeneratedReport: (
request: SaveGeneratedReportRequest
) => Promise<SaveGeneratedReportResult>
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
readDatabaseKeyClipboard: () => Promise<{
success: boolean
value?: string
error?: string
}>
autoGetDbKey: (options?: { save?: boolean }) => Promise<{
success: boolean
key?: string
error?: string
code?: string
saved?: boolean
warning?: string
}>
autoGetImageKey: (options?: { save?: boolean }) => Promise<{
success: boolean
xorKey?: number
aesKey?: string
verified?: boolean
error?: string
imageXorKey?: string
imageAesKey?: string
settings?: {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
imageKeyRoot: string
imageXorKey: string
imageAesKey: string
}
}>
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
testImageDecryption: (
request: TestImageDecryptionRequest
) => Promise<ImageDecryptionTestResult>
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
getSettings: () => Promise<{
settings: {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
imageKeyRoot: string
imageXorKey: string
imageAesKey: string
}
settingsPath: string
}>
setSettings: (
patch: Partial<{
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
imageKeyRoot: string
imageXorKey: string
imageAesKey: string
}>
) => Promise<{
settings: {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
imageKeyRoot: string
imageXorKey: string
imageAesKey: string
}
settingsPath: string
}>
getSelf: () => Promise<
| {
ready: true
info: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
}
| { ready: false }
>
testConnection: (key: string, accountRoot?: string) => Promise<DatabaseKeyValidationResult>
reopenWithRoot: (accountRoot: string) => Promise<{
success: boolean
error?: string
info?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
}>
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
disconnectDb: () => Promise<{ success: boolean; error?: string }>
apiStatus: () => Promise<{
running: boolean
host: string
port: number
error?: string
}>
apiStart: (
host?: string,
port?: number
) => Promise<{ running: boolean; host: string; port: number; error?: string }>
apiStop: () => Promise<{ running: boolean; host: string; port: number; error?: string }>
apiToggle: (enabled: boolean) => Promise<{
running: boolean
host: string
port: number
error?: string
}>
getReaderSkillStatus: () => Promise<{
available: boolean
version?: string
filePath?: string
directoryPath?: string
source: 'development' | 'bundled'
githubUrl: string
error?: string
}>
readReaderSkill: () => Promise<{ success: boolean; content?: string; error?: string }>
revealReaderSkill: () => Promise<{ success: boolean; error?: string }>
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
}
}
}
+103 -7
View File
@@ -1,18 +1,114 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
import type {
AIChatRequestOptions,
AIProviderConfig,
AIVisionTestRequest,
LegacyAIConfig
} from '../shared/ai-provider'
// 渲染器的自定义 API
const api = {
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
getMessages: (
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
aiChat: (
messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string }
) => ipcRenderer.invoke('ai:chat', messages, options),
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String)
aiChat: (messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
ipcRenderer.invoke('ai:chat', messages, options),
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
getAIRuntimeConfig: () => ipcRenderer.invoke('ai:getRuntimeConfig'),
saveAIProvider: (provider: AIProviderConfig) => ipcRenderer.invoke('ai:saveProvider', provider),
deleteAIProvider: (providerId: string) => ipcRenderer.invoke('ai:deleteProvider', providerId),
setDefaultAIProvider: (providerId: string) =>
ipcRenderer.invoke('ai:setDefaultProvider', providerId),
testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId),
testAIVision: (request: AIVisionTestRequest) => ipcRenderer.invoke('ai:testVision', request),
migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config),
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
parseMessage: (content: string, messageType: number) =>
ipcRenderer.invoke('db:parseMessage', content, messageType),
getImage: (
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean }
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
ipcRenderer.invoke('report:saveGenerated', request),
deleteGeneratedReport: (reportId: string) =>
ipcRenderer.invoke('report:deleteGenerated', reportId),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options),
autoGetImageKey: (options?: { save?: boolean }) =>
ipcRenderer.invoke('key:autoGetImageKey', options),
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
payload: { type: string; json: string }
): void => callback(payload)
ipcRenderer.on('wcdb-change', listener)
return () => ipcRenderer.removeListener('wcdb-change', listener)
},
onDbKeyStatus: (callback: (payload: { message: string }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
callback(payload)
ipcRenderer.on('key:dbKeyStatus', listener)
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
},
onImageKeyStatus: (callback: (payload: { message: string }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
callback(payload)
ipcRenderer.on('key:imageKeyStatus', listener)
return () => ipcRenderer.removeListener('key:imageKeyStatus', listener)
},
getSettings: () => ipcRenderer.invoke('settings:get'),
setSettings: (patch) => ipcRenderer.invoke('settings:set', patch),
getSelf: () => ipcRenderer.invoke('settings:getSelf'),
testConnection: (key: string, accountRoot?: string) =>
ipcRenderer.invoke('db:testConnection', key, accountRoot),
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
apiStop: () => ipcRenderer.invoke('api:stop'),
apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled),
getReaderSkillStatus: () => ipcRenderer.invoke('api:skillStatus'),
readReaderSkill: () => ipcRenderer.invoke('api:readSkill'),
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
copyText: (text: string) => ipcRenderer.invoke('api:copyText', text)
}
if (process.contextIsolated) {
+3 -2
View File
@@ -2,11 +2,12 @@
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<title>WechatExplorer</title>
<link rel="icon" type="image/svg+xml" href="/src/assets/brand-icon.svg" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
/>
</head>
+1185 -58
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" rx="48" fill="#022C22" />
<rect x="0.5" y="0.5" width="255" height="255" rx="47.5" stroke="#064E3B" />
<rect x="128" y="16.2742" width="158" height="158" rx="15" transform="rotate(45 128 16.2742)" stroke="#14B8A6" stroke-opacity="0.3" stroke-width="2" />
<rect x="65" y="65" width="126" height="126" rx="11" stroke="#14B8A6" stroke-opacity="0.5" stroke-width="2" />
<rect x="104" y="104" width="48" height="48" rx="24" fill="#14B8A6" />
<rect x="192" y="48" width="16" height="16" rx="8" fill="#2DD4BF" />
</svg>

After

Width:  |  Height:  |  Size: 656 B

File diff suppressed because it is too large Load Diff
+225 -405
View File
@@ -1,103 +1,178 @@
import React, { useEffect, useRef, useState } from 'react'
import { toPng } from 'html-to-image'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Message, Contact } from '../../../shared/types'
import { ChatHeader } from './chat/ChatHeader'
import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState'
import { ExportRange } from './chat/ExportMenu'
import { MessageList } from './chat/MessageList'
interface ChatWindowProps {
contact: Contact | null
messages: Message[]
isLoadingMessages?: boolean
contentFilter?: string
dateRange?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onCreateGroupReport?: () => void
isAiLoading?: boolean
}
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
, wxid id
7
-
- (50123🔥
- (5)
- ()
- (50200
- (50)
- 72727
27, 28, 29, 30, 31, 1, 2
- 线 ------------
const MAX_RENDERED_MESSAGES = 600
const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天',
yesterday: '昨日',
'7': '7 天',
'30': '30 天',
all: '全部'
}
1. 使------------
2. 使
3.
4.
5.
const formatClock = (date: Date): string =>
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
`
const formatRangeDate = (date: Date, now: Date): string => {
const clock = formatClock(date)
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}${clock}`
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${clock}`
}
const getChatHeaderRangeLabel = (range: string): string => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
if (range === 'today') return `今天 00:00—现在`
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
if (range === '7') {
const start = new Date(Date.now() - 7 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === '30') {
const start = new Date(Date.now() - 30 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === 'all') return '全部记录'
return DATE_RANGE_LABELS[range] || '当前范围'
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
messages,
isLoadingMessages,
contentFilter,
dateRange = 'today',
onContentFilterChange,
onRefresh,
onRefreshData
onRefreshData,
onCreateGroupReport,
isAiLoading = false
}) => {
const isGroupChat = contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
)
const messageListRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [showAvatar, setShowAvatar] = useState(false)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0)
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
const imageViewerStageRef = useRef<HTMLDivElement>(null)
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
)
const [showAvatar, setShowAvatar] = useState(true)
const [isAtLatest, setIsAtLatest] = useState(true)
const [colWidths, setColWidths] = useState([150, 100, 180, 400])
const [resizingColIndex, setResizingColIndex] = useState<number | null>(null)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
// AI Settings
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [apiKey, setApiKey] = useState(() => localStorage.getItem('deepseek_api_key') || '')
const [model, setModel] = useState('deepseek-chat')
const handleSaveSettings = (): void => {
localStorage.setItem('deepseek_api_key', apiKey)
setShowSettingsModal(false)
AIChat()
}
const scrollToBottom = (): void => {
const scrollToBottom = useCallback((): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}
setIsAtLatest(true)
}, [])
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
const target = event.currentTarget
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
setIsAtLatest(distanceToBottom <= 24)
}, [])
useEffect(() => {
scrollToBottom()
}, [messages])
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [messages, scrollToBottom])
const startResizing = (index: number, e: React.MouseEvent): void => {
e.preventDefault()
setResizingColIndex(index)
startXRef.current = e.clientX
startWidthRef.current = colWidths[index]
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
setImageScale(1)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleMouseMove = (e: MouseEvent): void => {
if (resizingColIndex === null) return
const diff = e.clientX - startXRef.current
const newWidth = Math.max(50, startWidthRef.current + diff)
const closeImagePreview = (): void => {
setPreviewImage(null)
imageDragRef.current = null
}
setColWidths((prev) => {
const newCols = [...prev]
newCols[resizingColIndex] = newWidth
return newCols
const zoomImage = (delta: number): void => {
setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
}
const resetImageTransform = (): void => {
setImageScale(1)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleViewerWheel = (event: React.WheelEvent): void => {
event.preventDefault()
event.stopPropagation()
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
}
const handleViewerMouseDown = (event: React.MouseEvent): void => {
event.preventDefault()
imageDragRef.current = {
x: event.clientX,
y: event.clientY,
offsetX: imageOffset.x,
offsetY: imageOffset.y
}
}
const handleViewerMouseMove = (event: React.MouseEvent): void => {
if (!imageDragRef.current) return
const drag = imageDragRef.current
setImageOffset({
x: drag.offsetX + event.clientX - drag.x,
y: drag.offsetY + event.clientY - drag.y
})
}
const handleMouseUp = (): void => {
setResizingColIndex(null)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
const handleViewerMouseUp = (): void => {
imageDragRef.current = null
}
const handleExport = (days: number | 'all'): void => {
useEffect(() => {
if (!previewImage) return
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const stage = imageViewerStageRef.current
const preventBackgroundWheel = (event: WheelEvent): void => {
event.preventDefault()
}
stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
return () => {
document.body.style.overflow = previousOverflow
stage?.removeEventListener('wheel', preventBackgroundWheel)
}
}, [previewImage])
const handleExport = (days: ExportRange): void => {
if (!messages.length) return
let filtered = messages
@@ -156,366 +231,111 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
document.body.removeChild(link)
}
const [summaryContent, setSummaryContent] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const AIChat = async (): Promise<void> => {
if (!messages || messages.length === 0) {
alert('当前没有消息可供总结')
return
}
const filteredMessages = messages
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
.map((msg) => {
const { img, id, isSender, ...rest } = msg
return rest
})
const recentMessages = filteredMessages
.map((msg) => {
return `${msg.datetime} ${msg.from}: ${msg.content}`
})
.join('\n')
const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}`
setIsLoading(true)
try {
console.log('正在请求AI...')
const result = await window.api.aiChat(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
],
{ apiKey, model }
)
if (result.success && result.data) {
console.log('AI Summary:', result.data)
setSummaryContent(result.data)
// 等待状态更新和渲染
setTimeout(() => {
textToImage()
setIsLoading(false) // 图片生成开始后停止加载
}, 500)
} else {
console.error('AI Error:', result.error)
alert(`AI 请求失败: ${result.error}`)
setIsLoading(false)
}
} catch (error) {
console.error('AI Call Failed:', error)
alert('AI 请求发生错误')
setIsLoading(false)
}
}
const textToImage = async (): Promise<void> => {
if (imageContainerRef.current) {
try {
const dataUrl = await toPng(imageContainerRef.current, {
cacheBust: true,
backgroundColor: '#ffffff',
style: {
transform: 'scale(1)'
}
})
if (dataUrl && dataUrl.length > 100) {
setGeneratedImage(dataUrl)
} else {
alert('生成图片为空')
}
} catch (err) {
console.error('Failed to generate image', err)
alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err)))
}
}
}
const handleCopyImage = async (): Promise<void> => {
if (!generatedImage) return
const result = await window.api.copyImage(generatedImage)
if (result.success) {
alert('复制成功')
}
}
const [displayLimit, setDisplayLimit] = useState(100)
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '分享消息,图片,表情包,视频')
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
.split(',')
.map((type) => type.trim())
.filter(Boolean)
const typeMatch = !filterTypes.includes(msg.type)
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
return typeMatch && contentMatch
})
}, [messages, contentFilter])
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
const renderedMessages = React.useMemo(
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
[filteredMessages]
)
const visibleMessages = filteredMessages.slice(0, displayLimit)
if (!contact) return <EmptyConversationState />
if (!contact) {
return (
<div className="chat-window">
<div className="empty-state"></div>
</div>
)
}
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
return (
<div className="chat-window">
<div className="chat-header">
<h2>{contact.m_nsNickName}</h2>
<div className="window-controls"></div>
</div>
<ChatHeader
contact={contact}
isGroupChat={isGroupChat}
dateRangeLabel={dateRangeLabel}
loadedCount={messages.length}
filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''}
isAiLoading={isAiLoading}
canExport={messages.length > 0}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
onExport={handleExport}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
/>
<DataTrustBar messageCount={messages.length} />
<MessageList
contact={contact}
messages={renderedMessages}
hiddenMessageCount={hiddenMessageCount}
isLoadingMessages={isLoadingMessages}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
listRef={messageListRef}
bottomRef={messagesEndRef}
onScroll={handleMessageListScroll}
onImageClick={openImagePreview}
/>
<ChatStatusBar
count={renderedMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
onShowAvatarChange={setShowAvatar}
onJumpToLatest={scrollToBottom}
/>
<div className="message-list">
<table className="chat-table" style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ width: colWidths[0], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(0, e)} />
</th>
<th style={{ width: colWidths[1], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(1, e)} />
</th>
<th style={{ width: colWidths[2], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(2, e)} />
</th>
<th style={{ width: colWidths[3] }}></th>
</tr>
</thead>
<tbody>
{visibleMessages.map((msg) => (
<tr key={msg.id}>
<td
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={msg.from}
>
{msg.from}
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.type}
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.datetime}
</td>
<td style={{ wordBreak: 'break-all', display: 'flex', alignItems: 'center' }}>
{showAvatar && msg?.img && (
<img style={{ width: '40px', height: '40px' }} src={msg?.img}></img>
)}
<div
style={{
justifyContent: 'center',
alignItems: 'center',
display: !showAvatar ? 'flex' : 'block'
}}
>
{(msg.name || contact.m_nsNickName) && (
<div style={{ display: 'flex', fontSize: 18 }}>
{isGroupChat ? msg.name : msg.from === 'user' ? contact.m_nsNickName : '我'}
<span>{isGroupChat ? (msg.name ? ':' : '') : ':'} </span>
</div>
)}
<div
style={{
fontSize: 18,
background: '#fff',
margin: 4,
padding: 4,
borderRadius: '4px'
}}
>
{msg.content}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredMessages.length > displayLimit && (
<div style={{ textAlign: 'center', padding: '10px' }}>
<button
onClick={() => setDisplayLimit((prev) => prev + 100)}
style={{
padding: '8px 16px',
backgroundColor: '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer'
}}
>
({filteredMessages.length - displayLimit} )
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-toolbar">
<label
style={{ marginRight: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<input
type="checkbox"
checked={showAvatar}
onChange={(e) => setShowAvatar(e.target.checked)}
style={{ marginRight: '5px' }}
/>
</label>
<button className="toolbar-btn" onClick={onRefresh}>
🔄
</button>
<button className="toolbar-btn" onClick={onRefreshData}>
🔄
</button>
<button className="toolbar-btn" onClick={() => handleExport('all')}>
📤
</button>
<button className="toolbar-btn" onClick={() => handleExport(0)}>
🕒
</button>
<button className="toolbar-btn" onClick={() => handleExport(1)}>
📅
</button>
<button className="toolbar-btn" onClick={() => handleExport(7)}>
📅 7
</button>
<button className="toolbar-btn" onClick={() => handleExport(30)}>
📅 30
</button>
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
🤖 AI总结群聊
</button>
</div>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: 0,
height: 0,
overflow: 'hidden',
zIndex: -1
}}
>
<div
style={{
width: '820px',
padding: '20px',
backgroundColor: '#fff',
fontSize: '22px',
color: '#000',
whiteSpace: 'pre-wrap',
fontFamily: 'sans-serif',
lineHeight: '1.5'
}}
ref={imageContainerRef}
>
{summaryContent}
</div>
</div>
{/* 加载模态框 */}
{isLoading && (
<div className="modal-overlay">
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
<div style={{ fontSize: '16px', color: '#333' }}> AI ...</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
{previewImage && (
<div className="image-viewer-overlay" onClick={closeImagePreview}>
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
<div className="image-viewer-titlebar">
<div className="image-viewer-tools">
<span className="image-viewer-title"></span>
<button onClick={() => zoomImage(-0.1)} title="缩小">
</button>
<span className="image-viewer-zoom">{Math.round(imageScale * 100)}%</span>
<button onClick={() => zoomImage(0.1)} title="放大">
+
</button>
<span className="image-viewer-divider" />
<button onClick={() => setImageRotation((prev) => prev - 90)} title="左旋转">
</button>
<button onClick={() => setImageRotation((prev) => prev + 90)} title="右旋转">
</button>
<button onClick={resetImageTransform} title="重置">
</button>
</div>
<button className="image-viewer-close" onClick={closeImagePreview} aria-label="关闭">
×
</button>
</div>
</div>
</div>
)}
{/* 图片预览模态框 */}
{generatedImage && (
<div className="modal-overlay" onClick={() => setGeneratedImage(null)}>
<div className="modal-content image-preview-modal" onClick={(e) => e.stopPropagation()}>
<img
src={generatedImage}
alt="Generated Summary"
style={{ maxWidth: '100%', maxHeight: '80vh', border: '1px solid #ccc' }}
/>
<div
style={{
marginTop: '10px',
display: 'flex',
// justifyContent: 'flex-end',
gap: '10px'
}}
ref={imageViewerStageRef}
className="image-viewer-stage"
onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown}
onMouseMove={handleViewerMouseMove}
onMouseUp={handleViewerMouseUp}
onMouseLeave={handleViewerMouseUp}
>
<button
onClick={handleCopyImage}
<img
src={previewImage}
alt="图片预览"
draggable={false}
style={{
padding: '8px 15px',
cursor: 'pointer',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px'
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`
}}
>
📋
</button>
<button
onClick={() => setGeneratedImage(null)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
>
</button>
</div>
</div>
</div>
)}
{/* AI Settings Modal */}
{showSettingsModal && (
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>AI </h3>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>:</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
style={{ width: '100%', padding: '8px' }}
>
<option value="deepseek-chat">DeepSeek Chat</option>
</select>
</div>
<div className="form-group" style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>API Key:</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your DeepSeek API Key"
style={{ width: '95%', padding: '8px' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button onClick={() => setShowSettingsModal(false)}></button>
<button
onClick={handleSaveSettings}
style={{
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
padding: '8px 15px',
borderRadius: '4px'
}}
>
</button>
</div>
</div>
</div>
)}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
interface ImageBubbleProps {
imageMd5?: string
imageDatName?: string
sessionId?: string
isThumb?: boolean
onImageClick?: (imageUrl: string) => void
}
export function ImageBubble({
imageMd5,
imageDatName,
sessionId,
isThumb = false,
onImageClick
}: ImageBubbleProps): JSX.Element {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isThumbnail, setIsThumbnail] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
setError('缺少图片标识')
return
}
setLoading(true)
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
if (result.success && result.data?.startsWith('data:image/')) {
setImageUrl(result.data)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
} else {
setError(result.error || '加载图片失败')
}
} catch {
setError('加载图片失败')
} finally {
setLoading(false)
}
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
useEffect(() => {
if (imageUrl || loading || error) return
const element = containerRef.current
if (!element || typeof IntersectionObserver === 'undefined') {
const timer = window.setTimeout(() => void loadImage(), 0)
return () => window.clearTimeout(timer)
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
observer.disconnect()
void loadImage()
},
{ rootMargin: '400px 0px' }
)
observer.observe(element)
return () => observer.disconnect()
}, [error, imageUrl, loadImage, loading])
const handleCopy = async (event: MouseEvent): Promise<void> => {
event.stopPropagation()
if (!imageUrl) return
await window.api.copyImage(imageUrl)
alert('图片已复制')
}
const handleClick = async (): Promise<void> => {
if (!imageUrl && !error) return
if (!imageMd5 && !imageDatName) {
if (imageUrl) onImageClick?.(imageUrl)
return
}
if (upgrading) {
if (imageUrl) onImageClick?.(imageUrl)
return
}
setUpgrading(true)
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
force: true
})
if (result.success && result.data?.startsWith('data:image/')) {
setImageUrl(result.data)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
onImageClick?.(result.data)
return
}
} catch {
// Fall back to the already visible image.
} finally {
setUpgrading(false)
}
if (imageUrl) onImageClick?.(imageUrl)
}
if (loading) {
return (
<div className="image-bubble image-loading" onClick={handleClick}>
<div className="image-loading-skeleton" aria-hidden />
<div className="image-quality-badge"></div>
</div>
)
}
if (error) {
return (
<div className="image-bubble image-error" onClick={loadImage}>
<div className="image-error-text">{error || '图片未缓存'}</div>
<div className="image-quality-badge"></div>
</div>
)
}
if (!imageUrl) {
return (
<div ref={containerRef} className="image-bubble image-placeholder">
<div className="image-loading-skeleton" aria-hidden />
<div className="image-quality-badge"></div>
</div>
)
}
return (
<div className="image-bubble image-loaded" onClick={handleClick}>
<img src={imageUrl} alt="图片" className="image-content" />
{(upgrading || isThumbnail) && (
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
)}
<div className="image-actions">
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
</button>
</div>
</div>
)
}
@@ -0,0 +1,231 @@
import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
const stickerDataUrlCache = new Map<string, string>()
interface RichMessageBubbleProps {
contentData: ParsedContent
}
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
switch (contentData.type) {
case 'location':
return <LocationBubble data={contentData} />
case 'card':
return <CardBubble data={contentData} />
case 'share':
return <ShareBubble data={contentData} />
case 'voip':
return <VoipBubble data={contentData} />
case 'sticker':
return <StickerBubble data={contentData} />
case 'quote':
return <QuoteBubble data={contentData} />
case 'system':
return <SystemBubble data={contentData} />
case 'unknown':
return (
<div className="message-text">
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')}
</div>
)
default:
return <div className="message-text">[]</div>
}
}
function LocationBubble({
data
}: {
data: Extract<ParsedContent, { type: 'location' }>
}): JSX.Element {
const { poiname, label, lat, lng } = data
const locationText = poiname || label || '位置'
const hasCoords = lat !== 0 || lng !== 0
const handleClick = (): void => {
if (hasCoords) {
const url = `https://maps.apple.com/?q=${encodeURIComponent(locationText)}&ll=${lat},${lng}`
window.open(url, '_blank')
}
}
return (
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
<div className="location-icon"></div>
<div className="location-info">
<div className="location-name">{locationText}</div>
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
{hasCoords && (
<div className="location-coords">
{lat.toFixed(6)}, {lng.toFixed(6)}
</div>
)}
</div>
</div>
)
}
function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }): JSX.Element {
const { username, nickname } = data
const handleCopy = (e: MouseEvent): void => {
e.stopPropagation()
navigator.clipboard.writeText(username)
}
return (
<div className="card-message">
<div className="card-avatar">{(nickname || username).charAt(0).toUpperCase()}</div>
<div className="card-info">
<div className="card-nickname">{nickname || '未知'}</div>
<div className="card-username" onClick={handleCopy} title="点击复制">
{username}
</div>
</div>
</div>
)
}
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
const { title, des, url, appname } = data
const handleClick = (): void => {
if (url) {
window.open(url, '_blank')
}
}
const urlHost = getUrlHost(url)
return (
<div className="share-message" onClick={handleClick}>
{appname && <div className="share-appname">{appname}</div>}
<div className="share-title">{title || '链接'}</div>
{des && <div className="share-desc">{des}</div>}
{urlHost && <div className="share-url">{urlHost}</div>}
</div>
)
}
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
const { status, roomType, duration } = data
const isVideo = roomType === 1
const formatDuration = (seconds: number | undefined): string => {
if (!seconds) return ''
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
if (mins > 0) {
return `${mins}${secs}`
}
return `${secs}`
}
return (
<div className="voip-message">
<span className="voip-icon">{isVideo ? '视频' : '通话'}</span>
<span className="voip-status">
{status}
{duration ? ` ${formatDuration(duration)}` : ''}
</span>
</div>
)
}
function StickerBubble({
data
}: {
data: Extract<ParsedContent, { type: 'sticker' }>
}): JSX.Element {
const { md5, url, thumbUrl } = data
const sourceUrl = url || thumbUrl || ''
const cacheKey = md5 || sourceUrl
const [displayUrl, setDisplayUrl] = useState(() =>
cacheKey ? stickerDataUrlCache.get(cacheKey) || '' : ''
)
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
const [error, setError] = useState(false)
useEffect(() => {
if (!cacheKey || displayUrl || error) return
let cancelled = false
window.api
.getSticker(sourceUrl, md5)
.then((result) => {
if (cancelled) return
if (result.success && result.data) {
stickerDataUrlCache.set(cacheKey, result.data)
setDisplayUrl(result.data)
setError(false)
} else {
setError(true)
}
})
.catch(() => {
if (!cancelled) setError(true)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [cacheKey, displayUrl, error, md5, sourceUrl])
if (displayUrl) {
return (
<div className="sticker-message">
<img src={displayUrl} alt="表情包" className="sticker-image" referrerPolicy="no-referrer" />
</div>
)
}
if (loading) {
return (
<div className="sticker-message">
<div className="sticker-placeholder">...</div>
</div>
)
}
return (
<div className="sticker-message">
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
</div>
)
}
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || ''
return (
<div className="quote-message">
<div className="quoted-message">
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
</div>
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
</div>
)
}
function SystemBubble({ data }: { data: Extract<ParsedContent, { type: 'system' }> }): JSX.Element {
return <div className="message-text">{renderWechatEmojiText(data.content || '[系统消息]')}</div>
}
function getUrlHost(url?: string): string {
if (!url) return ''
try {
return new URL(url).hostname
} catch {
return url
}
}
@@ -0,0 +1,482 @@
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
import React, { useEffect, useState } from 'react'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface AppSettings {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
imageKeyRoot: string
imageXorKey: string
imageAesKey: string
}
interface ApiState {
running: boolean
host: string
port: number
error?: string
}
interface AiModelConfig {
apiKey: string
baseURL: string
model: string
}
interface SettingsPanelProps {
open: boolean
selfInfo: SelfInfo | null
dbReady: boolean
dbKey: string
aiModelConfig: AiModelConfig
onClose: () => void
onDbKeyChange: (key: string) => void
onAiModelConfigChange: (config: AiModelConfig) => void
onSaveAiModelConfig: () => void
onDbRootChanged: () => void
}
const AI_MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
{ value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' },
{ value: 'moonshot-v1-8k', label: 'Moonshot V1' }
]
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
open,
selfInfo,
dbReady,
dbKey,
aiModelConfig,
onClose,
onDbKeyChange,
onAiModelConfigChange,
onSaveAiModelConfig,
onDbRootChanged
}) => {
const isWindows = window.electron.process.platform === 'win32'
const dbRootPlaceholder = isWindows
? 'C:\\Users\\你\\Documents\\WeChat Files'
: '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
const [settings, setSettings] = useState<AppSettings | null>(null)
const [settingsPath, setSettingsPath] = useState('')
const [apiState, setApiState] = useState<ApiState | null>(null)
const [testStatus, setTestStatus] = useState<
{ kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string }
>({ kind: 'idle', message: '' })
const [reopenStatus, setReopenStatus] = useState<string>('')
const [imageKeyStatus, setImageKeyStatus] = useState<{
kind: 'idle' | 'ok' | 'fail'
message: string
}>({ kind: 'idle', message: '' })
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!open) return
void refresh()
}, [open])
useEffect(() => {
if (!open) return
return window.api.onImageKeyStatus(({ message }) => {
setImageKeyStatus({ kind: 'idle', message })
})
}, [open])
async function refresh(): Promise<void> {
const [{ settings, settingsPath }, api] = await Promise.all([
window.api.getSettings(),
window.api.apiStatus()
])
setSettings(settings)
setSettingsPath(settingsPath)
setApiState(api)
}
if (!open) return null
async function handleSave(patch: Partial<AppSettings>): Promise<void> {
if (!settings) return
setBusy(true)
const next = await window.api.setSettings(patch)
setSettings(next.settings)
setBusy(false)
}
async function handleTest(): Promise<void> {
setTestStatus({ kind: 'idle', message: '测试中...' })
setBusy(true)
try {
const result = await window.api.testConnection(dbKey, settings?.dbRoot)
if (result.success) {
setTestStatus({
kind: 'ok',
message: '连接成功',
wxid: result.wxid,
accountRoot: result.accountRoot
})
if (result.accountRoot && settings && result.accountRoot !== settings.dbRoot) {
const next = await window.api.setSettings({ dbRoot: result.accountRoot })
setSettings(next.settings)
}
} else {
setTestStatus({ kind: 'fail', message: result.error || '连接失败' })
}
} catch (error) {
setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) })
} finally {
setBusy(false)
}
}
async function handleReopen(): Promise<void> {
if (!settings) return
setBusy(true)
setReopenStatus('重新初始化中...')
try {
const result = await window.api.reopenWithRoot(settings.dbRoot)
if (result.success) {
setReopenStatus(`已重新打开:${result.info?.wxid || '未知'}`)
onDbRootChanged()
} else {
setReopenStatus(result.error || '重新打开失败')
}
} catch (error) {
setReopenStatus(error instanceof Error ? error.message : String(error))
} finally {
setBusy(false)
}
}
async function handleApiToggle(enabled: boolean): Promise<void> {
setBusy(true)
await handleSave({ apiEnabled: enabled })
const state = await window.api.apiToggle(enabled)
setApiState(state)
setBusy(false)
}
async function handleApiRestart(): Promise<void> {
if (!settings) return
setBusy(true)
await window.api.apiStop()
const state = await window.api.apiStart(settings.apiHost, settings.apiPort)
setApiState(state)
setBusy(false)
}
async function handleAutoGetImageKey(): Promise<void> {
if (!settings) return
setBusy(true)
setImageKeyStatus({ kind: 'idle', message: '正在扫描微信内存获取图片密钥...' })
try {
const result = await window.api.autoGetImageKey()
if (!result.success || !result.aesKey) {
setImageKeyStatus({ kind: 'fail', message: result.error || '图片密钥获取失败' })
return
}
const imageXorKey =
result.imageXorKey ||
(typeof result.xorKey === 'number'
? `0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`
: settings.imageXorKey)
const imageAesKey = result.imageAesKey || result.aesKey
setSettings(result.settings || { ...settings, imageXorKey, imageAesKey })
setImageKeyStatus({
kind: 'ok',
message: result.verified ? '图片密钥已获取并校验通过' : '图片密钥已获取,未完成模板校验'
})
} catch (error) {
setImageKeyStatus({
kind: 'fail',
message: error instanceof Error ? error.message : String(error)
})
} finally {
setBusy(false)
}
}
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-header">
<h2></h2>
<button className="settings-close" onClick={onClose} title="关闭">
×
</button>
</div>
<div className="settings-body">
{/* 自我信息卡片 */}
<section className="settings-section">
<div className="settings-section-title"></div>
{dbReady && selfInfo ? (
<div className="settings-self">
<div className="settings-self-avatar">
{selfInfo.avatar ? (
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
) : (
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
)}
</div>
<div className="settings-self-info">
<div className="settings-self-nickname">{selfInfo.nickname}</div>
<div className="settings-self-wxid">{selfInfo.wxid}</div>
<div className="settings-self-account">{selfInfo.accountRoot}</div>
</div>
</div>
) : (
<div className="settings-self-empty"></div>
)}
</section>
{/* 测试连接 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<button
className="settings-btn settings-btn-primary"
onClick={handleTest}
disabled={busy || !dbKey}
>
</button>
{testStatus.kind !== 'idle' && (
<span className={`settings-status ${testStatus.kind}`}>
{testStatus.kind === 'ok' ? '✓' : '✗'} {testStatus.message}
{testStatus.wxid ? ` · ${testStatus.wxid}` : ''}
{testStatus.accountRoot ? ` · ${testStatus.accountRoot}` : ''}
</span>
)}
</div>
<div className="settings-hint">
使 + ,
</div>
</section>
{/* 解密密钥 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={dbKey}
onChange={(e) => onDbKeyChange(e.target.value)}
placeholder="64 位 hex 密钥,如 0x..."
spellCheck={false}
/>
</div>
<div className="settings-hint">
safeStorage
</div>
</section>
{/* 图片解密密钥 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={settings?.imageKeyRoot || settings?.dbRoot || ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageKeyRoot: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageKeyRoot: e.target.value })}
placeholder={dbRootPlaceholder}
spellCheck={false}
/>
</div>
<div className="settings-row">
<input
type="text"
className="settings-input settings-input-quarter"
value={settings?.imageXorKey ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageXorKey: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageXorKey: e.target.value })}
placeholder="XOR Key,如 0x40"
spellCheck={false}
/>
<input
type="text"
className="settings-input"
value={settings?.imageAesKey ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageAesKey: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageAesKey: e.target.value })}
placeholder="AES Key16 位字符"
spellCheck={false}
/>
<button className="settings-btn" onClick={handleAutoGetImageKey} disabled={busy}>
</button>
</div>
{imageKeyStatus.message && (
<div className={`settings-status ${imageKeyStatus.kind}`}>
{imageKeyStatus.kind === 'ok' ? '✓ ' : imageKeyStatus.kind === 'fail' ? '✗ ' : ''}
{imageKeyStatus.message}
</div>
)}
<div className="settings-hint">
使Windows 2-3
</div>
</section>
{/* 数据库根目录 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={settings?.dbRoot ?? ''}
onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)}
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
placeholder={dbRootPlaceholder}
spellCheck={false}
/>
</div>
<div className="settings-row">
<button
className="settings-btn"
onClick={handleReopen}
disabled={busy || !dbReady}
>
</button>
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
</div>
<div className="settings-hint">
Windows Documents\WeChat FilesmacOS xwechat_files db_storage/session.db
</div>
</section>
<section className="settings-section">
<div className="settings-section-title">AI </div>
<div className="settings-row">
<select
className="settings-input settings-input-half"
value={aiModelConfig.model}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, model: event.target.value })
}
>
{AI_MODEL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button className="settings-btn" onClick={onSaveAiModelConfig}>
AI
</button>
</div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={aiModelConfig.baseURL}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, baseURL: event.target.value })
}
placeholder="https://api.deepseek.com"
spellCheck={false}
/>
</div>
<div className="settings-row">
<input
type="password"
className="settings-input"
value={aiModelConfig.apiKey}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, apiKey: event.target.value })
}
placeholder="API Key"
spellCheck={false}
/>
</div>
<div className="settings-hint">
沿
localStorage
</div>
</section>
{/* API 服务 */}
<section className="settings-section">
<div className="settings-section-title"> HTTP API</div>
<div className="settings-row">
<label className="settings-toggle">
<input
type="checkbox"
checked={settings?.apiEnabled ?? false}
onChange={(e) => handleApiToggle(e.target.checked)}
disabled={busy}
/>
<span> API (127.0.0.1:6131)</span>
</label>
{apiState && (
<span className={`settings-status ${apiState.running ? 'ok' : 'fail'}`}>
{apiState.running ? '运行中' : '已停止'}
{apiState.error ? ` · ${apiState.error}` : ''}
</span>
)}
</div>
<div className="settings-row">
<input
type="text"
className="settings-input settings-input-half"
value={settings?.apiHost ?? ''}
onChange={(e) => setSettings(settings ? { ...settings, apiHost: e.target.value } : null)}
onBlur={(e) => handleSave({ apiHost: e.target.value })}
placeholder="host"
spellCheck={false}
/>
<input
type="number"
className="settings-input settings-input-quarter"
value={settings?.apiPort ?? 6131}
onChange={(e) =>
setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null)
}
onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })}
placeholder="port"
/>
<button className="settings-btn" onClick={handleApiRestart} disabled={busy}>
API
</button>
</div>
<div className="settings-hint">
API 访, Claude / Codex
<br />
:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
</div>
</section>
{/* 配置文件位置 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<code className="settings-path">{settingsPath}</code>
</div>
</section>
</div>
</div>
</div>
)
}
+5 -117
View File
@@ -1,118 +1,6 @@
import React, { useState } from 'react'
import { Contact } from '../../../shared/types'
import React from 'react'
import { ConversationSidebar, ConversationSidebarProps } from './conversation/ConversationSidebar'
interface SidebarProps {
contacts: Contact[]
selectedContact: Contact | null
onSelectContact: (contact: Contact) => void
onSearch: (keyword: string) => void
onContentFilter: (keyword: string) => void
width: number
dateRange: string
onDateRangeChange: (range: string) => void
}
export const Sidebar: React.FC<SidebarProps> = ({
contacts,
selectedContact,
onSelectContact,
onSearch,
onContentFilter,
width,
dateRange,
onDateRangeChange
}) => {
const [searchTerm, setSearchTerm] = useState('')
const [contentFilter, setContentFilter] = useState('')
const [isGroupsExpanded, setIsGroupsExpanded] = useState(true)
const [isContactsExpanded, setIsContactsExpanded] = useState(true)
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const term = e.target.value
setSearchTerm(term)
onSearch(term)
}
const handleContentFilterChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const term = e.target.value
setContentFilter(term)
onContentFilter(term)
}
const groups = contacts.filter((c) => c.type === 'group')
const users = contacts.filter((c) => c.type === 'user')
const renderContactItem = (contact: Contact): React.ReactElement => (
<div
key={contact.md5}
className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<div className="contact-avatar">{contact.m_nsNickName.charAt(0)}</div>
<div className="contact-info">
<div className="contact-name">{contact.m_nsNickName}</div>
</div>
</div>
)
return (
<div className="sidebar" style={{ width: width }}>
<div className="sidebar-header">
<input
type="text"
className="search-input"
placeholder="搜索联系人"
value={searchTerm}
onChange={handleSearchChange}
style={{ marginBottom: '8px' }}
/>
<input
type="text"
className="search-input"
placeholder="过滤消息内容"
value={contentFilter}
onChange={handleContentFilterChange}
/>
<div className="date-range-selector">
{[
{ key: 'today', label: '当天' },
{ key: 'yesterday', label: '昨日' },
{ key: '7', label: '近7天' },
{ key: '30', label: '近30天' },
{ key: 'all', label: '所有' }
].map((item) => (
<button
key={item.key}
className={`range-btn ${dateRange === item.key ? 'active' : ''}`}
onClick={() => onDateRangeChange(item.key)}
title={item.label}
>
{item.label}
</button>
))}
</div>
</div>
<div className="contact-list">
{/* 群聊部分 */}
<div className="section-header" onClick={() => setIsGroupsExpanded(!isGroupsExpanded)}>
<span className="arrow">{isGroupsExpanded ? '▼' : '▶'}</span> ({groups.length})
</div>
{isGroupsExpanded && groups.map(renderContactItem)}
{/* 联系人部分 */}
<div className="section-header" onClick={() => setIsContactsExpanded(!isContactsExpanded)}>
<span className="arrow">{isContactsExpanded ? '▼' : '▶'}</span> ({users.length})
</div>
{isContactsExpanded && users.map(renderContactItem)}
</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>
)
}
export const Sidebar: React.FC<ConversationSidebarProps> = (props) => (
<ConversationSidebar {...props} />
)
+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,67 @@
import React from 'react'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface AccountSummaryProps {
selfInfo: SelfInfo | null
dbReady: boolean
compact?: boolean
onClick?: () => void
}
export function AccountSummary({
selfInfo,
dbReady,
compact = false,
onClick
}: AccountSummaryProps): React.ReactElement {
const displayName =
dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置'
const statusText = dbReady ? '数据库已连接' : '数据库未连接'
const initial = (displayName || '?').charAt(0)
const title = `${displayName}\n${subtitle}`
const avatar = (
<span className="account-summary-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt={displayName} referrerPolicy="no-referrer" />
) : (
initial
)}
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
</span>
)
if (compact) {
return (
<button type="button" className="account-summary compact" onClick={onClick} title={title}>
{avatar}
</button>
)
}
return (
<div className="account-summary" title={title}>
{avatar}
<span className="account-summary-text">
<span className="account-summary-name">{displayName}</span>
<span className="account-summary-meta">{subtitle}</span>
<span className="account-summary-state">
<span className={`account-summary-state-dot ${dbReady ? 'ready' : ''}`} aria-hidden />
{statusText}
</span>
</span>
<button type="button" className="account-summary-settings" onClick={onClick} title="设置">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />
<path d="M19.4 15a8.2 8.2 0 0 0 .1-1l2-1.5-2-3.5-2.4 1a7.5 7.5 0 0 0-1.7-1l-.3-2.5h-4l-.4 2.5a7.5 7.5 0 0 0-1.7 1l-2.3-1-2 3.5 2 1.5a8.2 8.2 0 0 0 0 2l-2 1.5 2 3.5 2.3-1a7.5 7.5 0 0 0 1.7 1l.4 2.5h4l.3-2.5a7.5 7.5 0 0 0 1.7-1l2.4 1 2-3.5-2.1-1.5Z" />
</svg>
</button>
</div>
)
}
@@ -0,0 +1,136 @@
import React, { useEffect, useRef, useState } from 'react'
import { Contact } from '../../../../shared/types'
import { ConversationContentSearch } from './ConversationContentSearch'
import { ExportMenu, ExportRange } from './ExportMenu'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
interface ChatHeaderProps {
contact: Contact
isGroupChat: boolean
dateRangeLabel: string
loadedCount: number
filteredCount: number
contentFilter: string
isAiLoading: boolean
canExport: boolean
onContentFilterChange: (value: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onExport: (range: ExportRange) => void
onOpenAiSettings: () => void
}
export function ChatHeader({
contact,
isGroupChat,
dateRangeLabel,
loadedCount,
filteredCount,
contentFilter,
isAiLoading,
canExport,
onContentFilterChange,
onRefresh,
onRefreshData,
onExport,
onOpenAiSettings
}: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
const [moreOpen, setMoreOpen] = useState(false)
const moreRef = useRef<HTMLDivElement>(null)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const typeLabel = isGroupChat ? '群聊' : '联系人'
const visibleCount = contentFilter ? filteredCount : loadedCount
useEffect(() => {
if (!moreOpen) return
const handlePointerDown = (event: PointerEvent): void => {
if (!moreRef.current?.contains(event.target as Node)) setMoreOpen(false)
}
window.addEventListener('pointerdown', handlePointerDown)
return () => window.removeEventListener('pointerdown', handlePointerDown)
}, [moreOpen])
const handleCloseSearch = (): void => {
onContentFilterChange('')
setSearchOpen(false)
}
return (
<div className="chat-archive-header">
<div className="chat-title-block">
<div className="chat-title-avatar">
{contact.avatar ? (
<img src={contact.avatar} alt={displayName} referrerPolicy="no-referrer" />
) : (
displayName.charAt(0)
)}
</div>
<div className="chat-title-text">
<h2>{displayName}</h2>
<div className="chat-title-meta">
<span>{typeLabel}</span>
<span>{dateRangeLabel}</span>
<span>{visibleCount} </span>
</div>
</div>
</div>
<div className="chat-header-actions">
{searchOpen ? (
<ConversationContentSearch
value={contentFilter}
resultCount={filteredCount}
onChange={onContentFilterChange}
onClose={handleCloseSearch}
/>
) : (
<button
type="button"
className="chat-icon-button"
onClick={() => setSearchOpen(true)}
title="搜索当前聊天"
>
<SearchIcon />
</button>
)}
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
<RefreshIcon />
</button>
<ExportMenu disabled={!canExport} onExport={onExport} />
<div className="chat-menu" ref={moreRef}>
<button
type="button"
className="chat-icon-button"
onClick={() => setMoreOpen((current) => !current)}
title="更多"
>
<MoreIcon />
</button>
{moreOpen && (
<div className="chat-dropdown-menu right">
<button
type="button"
onClick={() => {
onRefreshData?.()
setMoreOpen(false)
}}
>
</button>
</div>
)}
</div>
<button
type="button"
className="chat-ai-button"
onClick={onOpenAiSettings}
disabled={isAiLoading}
title={isGroupChat ? '生成 AI 日报' : 'AI 日报当前仅支持群聊'}
>
<AiIcon />
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
</button>
</div>
</div>
)
}
@@ -0,0 +1,39 @@
import React from 'react'
interface ChatStatusBarProps {
count: number
showAvatar: boolean
isAtLatest: boolean
onShowAvatarChange: (show: boolean) => void
onJumpToLatest: () => void
}
export function ChatStatusBar({
count,
showAvatar,
isAtLatest,
onShowAvatarChange,
onJumpToLatest
}: ChatStatusBarProps): React.ReactElement {
const jumpDisabled = isAtLatest || count === 0
return (
<div className="chat-status-bar">
<div> {count} </div>
<div className="chat-status-actions">
<label className="chat-avatar-toggle">
<input
type="checkbox"
checked={showAvatar}
onChange={(event) => onShowAvatarChange(event.target.checked)}
/>
<span></span>
</label>
<span className="chat-status-separator" aria-hidden />
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
</button>
</div>
</div>
)
}
@@ -0,0 +1,37 @@
import React from 'react'
import { CloseIcon, SearchIcon } from './icons'
interface ConversationContentSearchProps {
value: string
resultCount: number
onChange: (value: string) => void
onClose: () => void
}
export function ConversationContentSearch({
value,
resultCount,
onChange,
onClose
}: ConversationContentSearchProps): React.ReactElement {
return (
<div className="chat-content-search">
<span className="chat-content-search-icon">
<SearchIcon />
</span>
<input
type="text"
value={value}
placeholder="搜索当前聊天内容"
onChange={(event) => onChange(event.target.value)}
autoFocus
/>
<span className="chat-content-search-count">
{value ? `${resultCount} 条结果` : '输入关键词'}
</span>
<button type="button" className="chat-icon-button" onClick={onClose} title="关闭搜索">
<CloseIcon />
</button>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More